fix conflict

This commit is contained in:
yaschalew
2026-07-02 09:28:02 +03:00
183 changed files with 10335 additions and 5286 deletions

View File

@@ -50,7 +50,7 @@ jobs:
SERVICES=()
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$"
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$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
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$"

View File

@@ -3,6 +3,10 @@
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
@@ -14,6 +18,7 @@ FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM base AS builder
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat

View File

@@ -6,7 +6,7 @@
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"dev": "nest start --watch --clearScreen false",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
@@ -18,6 +18,7 @@
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.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",
@@ -31,7 +32,8 @@
"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",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -83,13 +85,15 @@
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"vorpal": "^1.12.0"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -32,88 +32,78 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`);
}
const invoicesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoices';`,
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
if (!invoicesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
}
const invoiceLinesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoice_lines';`,
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
if (!invoiceLinesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
name = 'AddGrnNumberToWarehouseInventory1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Extend `freight.invoices` into the billing record of record for every source
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
*
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
* statuses the warehouse flow uses.
*
* Matches billing/entities/invoice.entity.ts. All columns are additive with
* defaults, so existing booking/demurrage rows are unaffected.
*/
export class ExtendInvoicesForPartialPayment1828000000000
implements MigrationInterface
{
name = "ExtendInvoicesForPartialPayment1828000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
// as the value is not referenced in the same transaction (it is not here).
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
);
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
`);
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
// the outstanding balance is the full total for unpaid invoices.
await queryRunner.query(`
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount;
`);
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
await queryRunner.query(`
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = updated_at
WHERE status = 'PAID';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS payments,
DROP COLUMN IF EXISTS paid_at,
DROP COLUMN IF EXISTS balance_amount,
DROP COLUMN IF EXISTS paid_amount,
DROP COLUMN IF EXISTS tax_amount,
DROP COLUMN IF EXISTS subtotal_amount;
`);
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
// left on freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -0,0 +1,222 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fold warehouse fee invoices into the central billing system.
*
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
* service is now a thin layer over `BillingService`. This migration backfills the
* existing rows (preserving ids, numbers, status, amounts and payment history),
* then drops the two legacy tables.
*
* Rows that cannot be billed centrally — no company to bill (`company_id` /
* `company_profile_id` underivable from the customer or the booking) — are not
* migrated; they could never have been charged through the gateway and are
* dropped with the table.
*/
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
name = 'CentralizeWarehouseInvoices1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Invoice headers. Keep the same id so items still link, and so any
// external reference to the invoice id stays valid.
await queryRunner.query(`
INSERT INTO freight.invoices (
id, invoice_number, company_id, company_profile_id,
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, status, source, source_id, type,
issued_at, paid_at, payments, payment_id, due_at,
created_at, updated_at, deleted_at
)
SELECT
fee.id,
fee.invoice_number,
COALESCE(fee.customer_id, b.company_id),
COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
),
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
fee.currency,
fee.status::freight.invoices_status_enum,
'warehouse',
fee.inventory_id,
fee.invoice_type,
fee.issued_at,
fee.paid_at,
COALESCE(fee.payments, '[]'::jsonb),
NULL,
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
fee.created_at, fee.updated_at, fee.deleted_at
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
AND COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
) IS NOT NULL
ON CONFLICT (id) DO NOTHING;
`);
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
// line's jsonb metadata.
await queryRunner.query(`
INSERT INTO freight.invoice_lines (
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
currency, metadata, created_at, updated_at, deleted_at
)
SELECT
item.id,
item.invoice_id,
item.fee_type,
item.description,
item.quantity,
item.unit_rate,
item.amount,
item.currency,
jsonb_build_object(
'feeRuleId', item.fee_rule_id,
'chargeableDays', item.chargeable_days,
'freeDays', item.free_days
),
item.created_at, item.updated_at, item.deleted_at
FROM freight.warehouse_fee_invoice_items item
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// 3. Drop the legacy tables (items first — FK to invoices).
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Recreate the legacy tables …
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_number varchar(40) NOT NULL,
booking_id uuid,
customer_id uuid,
inventory_id uuid NOT NULL,
facility_id uuid,
warehouse_id uuid,
yard_id uuid,
zone_id uuid,
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
status varchar(20) NOT NULL DEFAULT 'DRAFT',
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
total_amount numeric(14,2) NOT NULL DEFAULT 0,
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
period_start timestamptz,
period_end timestamptz,
issued_at timestamptz,
due_date timestamptz,
paid_at timestamptz,
cancelled_at timestamptz,
payments jsonb NOT NULL DEFAULT '[]',
notes text,
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_id uuid NOT NULL,
fee_rule_id uuid,
fee_type varchar(32) NOT NULL,
description varchar(255) NOT NULL,
quantity numeric(12,2) NOT NULL DEFAULT 1,
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
chargeable_days int,
free_days int,
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
);
// … then copy the warehouse-source invoices back, deriving the typed FKs and
// period from the linked inventory item.
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoices (
id, created_at, updated_at, deleted_at, invoice_number,
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
)
SELECT
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
i.payments, NULL
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoice_items (
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
)
SELECT
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
l.charge_type,
COALESCE(l.description, ''),
l.quantity, l.unit_rate, l.amount, l.currency,
NULLIF(l.metadata->>'chargeableDays', '')::int,
NULLIF(l.metadata->>'freeDays', '')::int
FROM freight.invoice_lines l
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// Remove the migrated rows from the central tables.
await queryRunner.query(`
DELETE FROM freight.invoice_lines
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
`);
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
* window closes before settlement (e.g. a booking whose `paymentDeadline`
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
* `OVERDUE` (still payable).
*
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
* not referenced in this same transaction, so it is PG 12+ safe.
*/
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
name = "AddExpiredInvoiceStatus1830000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
);
}
public async down(): Promise<void> {
// Postgres cannot drop individual enum values; EXPIRED is left on
// freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
@@ -21,4 +22,26 @@ export class BillingController {
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Get("invoices/:id/document")
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
sendPdf(res, filename, buffer);
}
@Get("invoices/:id/receipt")
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);
sendPdf(res, filename, buffer);
}
}
/** Stream a generated PDF as a file download. */
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
res.send(buffer);
}

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
@@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module";
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
],
controllers: [BillingController, PortalBillingController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
});
@@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
@@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -180,6 +183,89 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
@@ -200,6 +286,7 @@ describe("BillingService.settlePayable", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
@@ -234,6 +321,7 @@ describe("BillingService.settlePayable", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(

View File

@@ -1,15 +1,28 @@
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
@@ -20,13 +33,26 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -56,7 +82,11 @@ export interface GenerateInvoiceInput {
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
@@ -95,6 +125,7 @@ export class BillingService {
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -115,6 +146,89 @@ export class BillingService {
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
const totals: InvoiceDocumentModel["totals"] = [
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
];
if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
}
totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
totals,
};
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
@@ -127,19 +241,33 @@ export class BillingService {
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
/**
* Every invoice billed to a company, newest first, with billing relations.
* Optionally narrow to a single source record (e.g. a booking's invoices) via
* `{ source, sourceId }`.
*/
findByCompany(
companyId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
where: {
companyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
return companyId ? this.findByCompany(companyId, filter) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
@@ -173,20 +301,32 @@ export class BillingService {
);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.document(id);
}
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
async receiptForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.receipt(id);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
});
}
/**
@@ -230,8 +370,11 @@ export class BillingService {
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
@@ -250,7 +393,12 @@ export class BillingService {
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
@@ -293,6 +441,83 @@ export class BillingService {
);
}
/**
* Record a (possibly partial) settlement against an invoice and sync its
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
* balance reaches zero — PAID, stamping `paidAt` and emitting
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
* at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
* transaction.
*/
async recordPayment(
invoiceId: string,
input: RecordPaymentInput,
manager?: EntityManager,
): Promise<Invoice> {
if (!(input.amount > 0)) {
throw new BadRequestException(
"Payment amount must be greater than zero.",
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
await mg.update(Invoice, { id: invoice.id }, {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as never);
const updated = {
...invoice,
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as Invoice;
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
@@ -455,6 +680,72 @@ export class BillingService {
return this.markInvoiceAsRefunded(invoice.id, mg);
}
/**
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
*
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
*/
async expirePayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
"expired",
{},
mg,
);
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
*/
async syncPayableDueDate(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
}
async updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { status });
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
@@ -481,17 +772,20 @@ export class BillingService {
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`,
);
}
const result = await this.payment.initiate({
referenceId: sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
// link is the intent id (`paymentId`); per-source post-payment reactions live
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
currency: invoice.currency,

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { InvoiceDocumentService } from "./invoice-document.service";
import { PdfRenderService } from "./pdf-render.service";
/**
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],
exports: [PdfRenderService, InvoiceDocumentService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,179 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
category?: string | null;
quantity?: number | null;
unitRate?: number | null;
amount?: number | null;
currency?: string | null;
}
/** A labelled total row in the totals box; mark `grand` for the headline total. */
export interface InvoiceDocumentTotal {
label: string;
amount: number;
grand?: boolean;
}
/**
* Source-agnostic description of a printable invoice/receipt. Each billing
* source maps its own entity onto this shape; the renderer owns the layout so
* every EDR invoice document looks identical regardless of source.
*/
export interface InvoiceDocumentModel {
kind: InvoiceDocumentKind;
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
title: string;
documentNumber: string;
issuedAt?: Date | string | null;
status: string;
currency: string;
/** Free-form summary grid (label/value pairs). */
summary: Array<{ label: string; value: string | null }>;
/** Header for the line-item category column; column hidden when omitted. */
categoryHeader?: string;
lines: InvoiceDocumentLine[];
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
}
/**
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
* `WarehouseInvoiceService`; it now serves all invoices.
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
};
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
const itemRows = model.lines
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
</tr>`,
)
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
)
.join("");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(model.documentNumber)}</strong>
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>
<tr>
<th>Description</th>
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${itemRows}
</tbody>
</table>
<div class="totals">${totalRows}</div>
<div class="footer">
<div class="line">Prepared by EDR finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
}
}

View File

@@ -0,0 +1,160 @@
import { existsSync } from "fs";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="edr-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced.
*/
fallback?: (preparedHtml: string) => Buffer;
}
/**
* Generic HTML → PDF renderer shared by every document producer (invoices,
* receipts, warehouse release orders). Renders via headless Chromium when
* available and degrades to a caller-supplied (or generic) hand-built PDF
* otherwise. This is pure infrastructure — it knows nothing about invoices.
*/
@Injectable()
export class PdfRenderService {
private readonly logger = new Logger(PdfRenderService.name);
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
const label = opts.label ?? "document";
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import("puppeteer");
const launchOptions: import("puppeteer").LaunchOptions = {
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
}
this.logger.log(
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
];
return candidates.find((path) => existsSync(path));
}
isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
private genericFallbackPdf(html: string): Buffer {
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
const stream =
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
"ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
}

View File

@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
/** A single recorded settlement against an invoice (payment ledger entry). */
export interface InvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
/** ISO timestamp of when the settlement was recorded. */
paidAt: string;
metadata?: Record<string, unknown> | null;
}
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
/** Cumulative amount settled so far (supports partial payment). */
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
paidAmount!: number;
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** Set when the invoice is fully settled. */
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
/** Ledger of individual settlements (manual or gateway), newest last. */
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
payments!: InvoicePayment[];
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;

View File

@@ -0,0 +1,44 @@
/**
* Shared per-day sequential invoice numbering, used by every billing source
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
* service.
*
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
* the day + 1. Run inside the caller's transaction (pass that transaction's
* manager) so concurrent generation within a transaction stays consistent.
*/
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
}
export interface InvoiceNumberOptions {
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
table: string;
/** Document code prefix, e.g. `FRT` or `WHF`. */
code: string;
/** Column holding the number; defaults to `invoice_number`. */
column?: string;
/** Clock injection point (tests); defaults to now. */
now?: Date;
}
export async function nextDailyInvoiceNumber(
runner: SqlRunner,
opts: InvoiceNumberOptions,
): Promise<string> {
const now = opts.now ?? new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `${opts.code}-${ymd}-`;
const column = opts.column ?? "invoice_number";
const [row] = await runner.query(
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}

View File

@@ -0,0 +1,36 @@
/**
* Shared payment/settlement math for invoices. Both the global
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
* payment the same way — accumulate `paidAmount`, derive the outstanding
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
* it here means the two flows can never drift on rounding or the
* partial-vs-full threshold.
*/
/** Round to 2 decimals, avoiding binary float drift. */
export const round2 = (n: number): number => Math.round(n * 100) / 100;
export interface SettlementResult {
/** New cumulative amount paid. */
paidAmount: number;
/** Remaining balance (0 once fully paid). */
balanceAmount: number;
/** True once the balance reaches zero. */
fullyPaid: boolean;
}
/**
* Apply a single payment of `amount` to an invoice with `totalAmount` already
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
* the invoice being in a payable state.
*/
export function applySettlement(
totalAmount: number,
currentPaid: number,
amount: number,
): SettlementResult {
const total = Number(totalAmount);
const paidAmount = round2(Number(currentPaid) + Number(amount));
const balanceAmount = Math.max(0, round2(total - paidAmount));
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
}

View File

@@ -5,14 +5,18 @@ import {
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
@@ -29,8 +33,15 @@ export class PortalBillingController {
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
findMine(
@CurrentUser() user: AuthUserPayload,
@Query("source") source?: string,
@Query("sourceId") sourceId?: string,
) {
return this.billingService.findForUser(resolveAuthUserId(user), {
source,
sourceId,
});
}
@Get("my-invoices/:id")
@@ -42,6 +53,34 @@ export class PortalBillingController {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Get("my-invoices/:id/document")
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
async document(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.documentForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Get("my-invoices/:id/receipt")
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
async receipt(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.receiptForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(

View File

@@ -1,20 +1,20 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource } from "typeorm";
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
@@ -23,6 +23,12 @@ interface StoredPricingBreakdown {
currency?: string;
}
export interface InvoiceOptions {
dueDate?: Date;
invoiceType?: string;
invoiceStatus?: Freight.InvoiceStatus;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
@@ -56,11 +62,14 @@ export class BookingInvoiceService {
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
async ensureInvoiceForBooking(
booking: Booking,
invoiceOptions: InvoiceOptions = {},
): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
"PREPAID",
);
if (existing) return existing;
@@ -68,16 +77,9 @@ export class BookingInvoiceService {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
const input = this.buildInput(booking, invoiceOptions);
return this.billing.generateInvoice(input);
}
@@ -87,10 +89,10 @@ export class BookingInvoiceService {
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
@OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
case "PREPAID":
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
@@ -100,6 +102,8 @@ export class BookingInvoiceService {
}
}
updateStatus = this.billing.updateStatus;
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
@@ -114,16 +118,18 @@ export class BookingInvoiceService {
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
this.logger.warn(
`Cannot advance unknown booking ${bookingId} on payment.`,
);
return;
}
if (booking.paymentStatus === 'PAID') return;
if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
{ paymentStatus: "PAID", status: "PAID" },
);
await this.firstMile.acceptBooking(bookingId);
});
@@ -138,9 +144,13 @@ export class BookingInvoiceService {
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
private buildInput(
booking: Booking,
invoiceOptions: InvoiceOptions = {},
): GenerateInvoiceInput {
const breakdown = (booking.pricingBreakdown ??
{}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
@@ -155,10 +165,10 @@ export class BookingInvoiceService {
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price");
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
chargeType: "FREIGHT",
description: "Rail freight",
quantity: 1,
unitRate: amount,
amount,
@@ -166,7 +176,9 @@ export class BookingInvoiceService {
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
const subtotal = round2(
lines.reduce((sum, l) => sum + Number(l.amount), 0),
);
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
@@ -176,8 +188,8 @@ export class BookingInvoiceService {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
chargeType: "ADJUSTMENT",
description: "Staff price adjustment",
quantity: 1,
unitRate: delta,
amount: delta,
@@ -190,12 +202,14 @@ export class BookingInvoiceService {
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
dueAt: invoiceOptions.dueDate,
type: invoiceOptions.invoiceType ?? "PREPAID",
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
};
}
}

View File

@@ -4,53 +4,56 @@ import {
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { eatDay } from "../train-scheduling/batch-window.util";
import { isRoadService } from "./road.util";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingsRepository } from "./bookings.repository";
import { assertBookingStatus } from "./booking-status.util";
import { clearanceCodesForBooking } from "./clearance.util";
import {
computeNextStep,
type BookingNextStep,
} from "./booking-next-step.util";
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { Booking } from "./entities/booking.entity";
import { BookingsService } from "./bookings.service";
import { BookingInvoiceService } from "./booking-invoice.service";
import { Freight } from "@edr/types";
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
private readonly invoiceService: BookingInvoiceService,
) { }
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
"Generate a price before submitting (POST /bookings/:id/generate-price)",
);
}
@@ -69,7 +72,8 @@ export class BookingTransitionService {
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
@@ -79,7 +83,7 @@ export class BookingTransitionService {
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
} as never);
@@ -109,7 +113,7 @@ export class BookingTransitionService {
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
status: "PRICE_CHANGED_PENDING_CONFIRM",
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
@@ -121,16 +125,17 @@ export class BookingTransitionService {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
message:
"Price has changed since preview. Confirm to submit with the updated price.",
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
throw new BadRequestException("No price to confirm");
}
const computed = await this.pricingService.computePriceForBooking(booking);
@@ -149,9 +154,10 @@ export class BookingTransitionService {
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
@@ -173,7 +179,7 @@ export class BookingTransitionService {
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
message: "Booking submitted with confirmed price.",
};
}
@@ -183,17 +189,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
status: "CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -203,7 +209,7 @@ export class BookingTransitionService {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
@@ -217,14 +223,14 @@ export class BookingTransitionService {
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
// The backoffice must define how long the accepted contract stays valid.
// Without a window the contract has no end date and cannot be relied on, so
// accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.',
"A contract validity (in days) is required to accept this booking.",
);
}
@@ -234,12 +240,12 @@ export class BookingTransitionService {
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
status: "PENDING_APPROVAL",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
@@ -255,17 +261,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -283,8 +289,8 @@ export class BookingTransitionService {
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
@@ -296,14 +302,17 @@ export class BookingTransitionService {
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
"Approval steps must be completed in order",
);
}
@@ -315,29 +324,36 @@ export class BookingTransitionService {
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
@@ -359,90 +375,64 @@ export class BookingTransitionService {
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
assertBookingStatus(booking, ["CONTRACT_READY"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
assertBookingStatus(booking, ["PAID"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
status: "IN_TRANSIT",
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
assertBookingStatus(booking, ["IN_TRANSIT"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
status: "COMPLETED",
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
@@ -451,22 +441,22 @@ export class BookingTransitionService {
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"CONTRACT_READY",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
status: "CANCELLED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -479,20 +469,20 @@ export class BookingTransitionService {
async reject(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'PENDING_CONSOLIDATION',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_CONSOLIDATION",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason?.trim() || 'Customer rejected the price estimate.',
'REJECTION',
reason?.trim() || "Customer rejected the price estimate.",
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -513,10 +503,10 @@ export class BookingTransitionService {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
uploadedBy: "customer" | "gl";
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
}>;
allApproved: boolean;
@@ -525,20 +515,21 @@ export class BookingTransitionService {
const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const files = await this.filesService.findByResource(bookingId, "bookings");
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviews =
await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const documents: Awaited<
ReturnType<BookingTransitionService['getClearanceView']>
>['documents'] = [];
ReturnType<BookingTransitionService["getClearanceView"]>
>["documents"] = [];
const pushSetting = async (
code: string | null,
uploadedBy: 'customer' | 'gl',
uploadedBy: "customer" | "gl",
) => {
if (!code) return;
let setting;
@@ -556,28 +547,26 @@ export class BookingTransitionService {
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file
? { id: file.id, name: file.name, url: file.url }
: null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
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;
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',
uploadedBy: "customer",
settingCode: "custom",
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
@@ -611,13 +600,15 @@ export class BookingTransitionService {
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
const reviews = await this.bookingsRepository.findDocumentReviews(
booking.id,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
r.status === "APPROVED",
),
);
}
@@ -632,33 +623,38 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException('This booking has no document-clearance step');
throw new BadRequestException(
"This booking has no document-clearance step",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
if (booking.status === "AWAITING_DOCUMENTS") {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith('custom_')
? 'custom'
const settingCode = file.fieldname.startsWith("custom_")
? "custom"
: inputCode;
await this.bookingsRepository.upsertDocumentReviewPending({
bookingId,
@@ -669,7 +665,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(bookingId, {
status: 'DOCUMENTS_UNDER_REVIEW',
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -694,7 +690,10 @@ export class BookingTransitionService {
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const existing = await this.filesService.findByResource(
bookingId,
"bookings",
);
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
@@ -702,7 +701,7 @@ export class BookingTransitionService {
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
const labels = missing.map((f) => f.fileLabel).join(", ");
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
@@ -713,22 +712,27 @@ export class BookingTransitionService {
async reviewDocument(
bookingId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
status: "APPROVED" | "QUERIED",
staffId: string,
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
const existing =
await this.bookingsRepository.findDocumentReviews(bookingId);
const match = existing.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
(fileKey.startsWith("custom_")
? "custom"
: (inputCode ?? outputCode ?? "custom"));
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
if (status === "QUERIED" && !note?.trim()) {
throw new BadRequestException(
"A note is required when querying a document",
);
}
await this.bookingsRepository.setDocumentReviewStatus(
@@ -739,11 +743,11 @@ export class BookingTransitionService {
staffId,
note,
);
if (status === 'QUERIED') {
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
staffId,
);
}
@@ -756,18 +760,20 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { outputCode } = clearanceCodesForBooking(booking);
if (!outputCode) {
throw new BadRequestException('This booking has no customs output documents');
throw new BadRequestException(
"This booking has no customs output documents",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
@@ -781,19 +787,23 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const approved = await this.isClearanceFullyApproved(booking);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
"All required documents must be approved before clearance can be finalized",
);
}
const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const setting =
await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(
bookingId,
"bookings",
);
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
@@ -802,13 +812,13 @@ export class BookingTransitionService {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
.join(", ")}`,
);
}
}
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
status: "CLEARANCE_READY",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -827,11 +837,14 @@ export class BookingTransitionService {
scheduledDate: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
assertBookingStatus(booking, [
"CLEARANCE_READY",
"OPERATION_CHANGES_REQUESTED",
]);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException('A valid schedule date is required');
throw new BadRequestException("A valid schedule date is required");
}
// The binding shipment day must have at least one OPEN departure on the
@@ -844,12 +857,12 @@ export class BookingTransitionService {
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
"No departures available on the selected day for this route",
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
@@ -865,27 +878,27 @@ export class BookingTransitionService {
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES',
decision: "ACCEPT" | "REQUEST_CHANGES",
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
if (decision === 'REQUEST_CHANGES') {
if (decision === "REQUEST_CHANGES") {
if (!options.note?.trim()) {
throw new BadRequestException(
'A note is required when requesting changes',
"A note is required when requesting changes",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
options.note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
status: "OPERATION_CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -907,9 +920,17 @@ export class BookingTransitionService {
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
);
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING',
status: "ROAD_DISPATCH_PENDING",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -917,7 +938,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -932,21 +953,23 @@ export class BookingTransitionService {
return this.bookingsService.findById(booking.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
@@ -957,4 +980,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -14,12 +14,12 @@ import {
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 { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiBody,
@@ -27,20 +27,20 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingsService } from "./bookings.service";
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import {
AcceptIntakeDto,
ApproveStepDto,
@@ -52,18 +52,21 @@ import {
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
} from "../../common/resolve-auth-user-id";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
@ApiTags('bookings')
@Controller('bookings')
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
export class BookingsController {
constructor(
@@ -72,12 +75,12 @@ export class BookingsController {
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
) {}
) { }
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@ApiBody({ type: CreateBookingDto })
async create(
@Body() dto: CreateBookingDto,
@@ -87,15 +90,24 @@ export class BookingsController {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
const result = await this.bookingsService.create(
dto,
files ?? [],
user?.id,
);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.bookings.staffAccept,
);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
const submitted = await this.bookingsService.findById(
result.booking.id,
);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
@@ -105,16 +117,16 @@ export class BookingsController {
return result;
}
@Patch(':id')
@Patch(":id")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
summary: "Update booking",
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
@@ -122,7 +134,7 @@ export class BookingsController {
}
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
@@ -139,7 +151,7 @@ export class BookingsController {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
if (!userId) throw new UnauthorizedException("Authentication required");
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
@@ -165,27 +177,29 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId);
}
@Get('by-company/:companyId/customer-view')
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
@Get("by-company/:companyId/customer-view")
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@Get("list-summary")
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@Get("my")
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
"Bookings owned by the authenticated user's company that are payable " +
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@@ -194,32 +208,32 @@ export class BookingsController {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@Get("queues/:queue")
@ApiOperation({
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
})
findQueue(
@Param('queue') queue: string,
@Param("queue") queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
@Query("excludeBulk") excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
excludeBulk: excludeBulk === "true",
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@Get("reference-data")
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
@Get("by-reference/:reference")
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param('reference') reference: string,
@Param("reference") reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
@@ -233,10 +247,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
@Get(":id")
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -254,15 +268,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@Get(":id/tracking")
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
summary: "Shipment tracking timeline for a booking",
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
"tracking events. Scoped to the customer's own company.",
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -276,66 +290,66 @@ export class BookingsController {
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@Post(":id/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@Post(":id/generate-price")
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@Post(":id/submit")
@ApiOperation({
summary: 'Customer submit booking',
summary: "Customer submit booking",
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
submit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@Post(":id/confirm-submit")
@ApiOperation({
summary: 'Confirm submit after price change',
summary: "Confirm submit after price change",
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@Post(":id/reject")
@ApiOperation({
summary: 'Customer reject price estimate',
summary: "Customer reject price estimate",
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
@@ -344,22 +358,23 @@ export class BookingsController {
// ── Document clearance (post counter-sign) ────────────────────────────────
@Get(':id/clearance')
@Get(":id/clearance")
@ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)',
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param('id', ParseUUIDPipe) id: string) {
getClearance(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@Post(":id/clearance/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)',
summary: "Customer uploads clearance documents (fieldname = document key)",
})
async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
@@ -369,14 +384,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/proceed')
@Post(":id/clearance/proceed")
@ApiOperation({
summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
"Customer requests operation with a schedule day " +
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
@@ -386,15 +401,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -407,11 +422,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/review')
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -425,13 +442,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/output-documents')
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
@@ -441,21 +458,22 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize')
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@Post(":id/staff/request-changes")
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
@ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -467,14 +485,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@Post(":id/staff/accept")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
"Staff accept intake → set contract validity window + start approval chain",
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -486,11 +504,11 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@Post(":id/staff/reject")
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
@ApiOperation({ summary: "Staff final reject" })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -502,11 +520,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
@@ -516,16 +536,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
@@ -539,12 +559,12 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -557,53 +577,53 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Generate contract PDF from template" })
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/view')
@Get(":id/contract/view")
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
@Get(":id/contract/document")
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
res.setHeader(
'Content-Disposition',
"Content-Disposition",
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
@Get(":id/contract")
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
@Post(":id/contract/sign")
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
@@ -615,28 +635,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/contract/signatures")
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/summary")
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@Post(":id/customer/sign")
@ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
@@ -644,20 +664,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@Post(":id/marketing/approve")
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
summary:
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
role: "STAFF",
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
@@ -666,48 +687,48 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@Post(":id/operations/start-transit")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark in transit" })
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@Post(":id/operations/complete")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark completed" })
async complete(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
@ApiOperation({ summary: "Cancel booking" })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Delete(":id/consolidation")
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/consolidation")
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,44 +1,44 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { Module, forwardRef } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
// import { CustomersModule } from '../customers/customers.module';
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 { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
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 { SignaturesModule } from "../signatures/signatures.module";
import { BillingModule } from "../billing/billing.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { BookingContractService } from "./booking-contract.service";
import { BookingInvoiceService } from "./booking-invoice.service";
import { BookingPaymentController } from "./booking-payment.controller";
import { BookingPaymentService } from "./booking-payment.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingsController } from "./bookings.controller";
import { PayController } from "./pay.controller";
import { BookingsRepository } from "./bookings.repository";
import { ConsolidationService } from "./consolidation.service";
import { BookingsService } from "./bookings.service";
import { BookingApprovalStep } from "./entities/booking-approval-step.entity";
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity";
import { BookingDocumentReview } from "./entities/booking-document-review.entity";
import { BookingContainer } from "./entities/booking-container.entity";
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity";
import { BookingContractSignature } from "./entities/booking-contract-signature.entity";
import { BookingReviewNote } from "./entities/booking-review-note.entity";
import { Booking } from "./entities/booking.entity";
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPdfService } from "../../contracts/contract-pdf.service";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
@Module({
imports: [
@@ -66,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}),
],
controllers: [BookingsController, PayController, BookingPaymentController],
@@ -86,6 +86,11 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
],
})
export class BookingsModule {}
export class BookingsModule { }

View File

@@ -6,8 +6,6 @@ import {
ParseUUIDPipe,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -18,9 +16,9 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import { IntentStatusDto, RefundDto } from "./payments.dto";
import { IntentStatusDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,13 +71,6 @@ export class PaymentController {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto);
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
);
});

View File

@@ -4,22 +4,24 @@ import {
Logger,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
} from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { Cron, SchedulerRegistry } from "@nestjs/schedule";
import { DataSource } from "typeorm";
import { Freight } from "@edr/types";
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { BillingService } from "../billing/billing.service";
import { Booking } from "../bookings/entities/booking.entity";
import { BookingsRepository } from "../bookings/bookings.repository";
import { Locomotive } from "../locomotives/entities/locomotive.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
import { BookingNotifierService } from "./booking-notifier.service";
import { TrainSchedulingService } from "./train-scheduling.service";
import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -27,13 +29,13 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
} from "./booking-batch.constants";
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
} from "./train-capacity.util";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -53,12 +55,12 @@ interface RouteDayGroup {
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'SELECTED_FOR_BATCH'
| 'READY'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
| "ALLOCATED"
| "SELECTED_FOR_BATCH"
| "READY"
| "WAITING"
| "PENDING_CONTRACT"
| "EXPIRED";
export interface BatchBoardBooking {
id: string;
@@ -73,10 +75,10 @@ export interface BatchBoardBooking {
}
export type BookingAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
| "NOT_ATTEMPTED"
| "ASSIGNED"
| "DEFERRED"
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
@@ -114,9 +116,9 @@ export interface BatchBoardScheduleDetail {
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive'];
capacity: BatchBoardSchedule['capacity'];
counts: BatchBoardSchedule['counts'];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
@@ -179,7 +181,8 @@ export class BookingBatchService implements OnModuleInit {
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
) {}
private readonly billing: BillingService,
) { }
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
@@ -195,10 +198,10 @@ export class BookingBatchService implements OnModuleInit {
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL')
.andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
@@ -276,7 +279,7 @@ export class BookingBatchService implements OnModuleInit {
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
where: { bookingWindowStatus: "OPEN" },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
@@ -310,25 +313,29 @@ export class BookingBatchService implements OnModuleInit {
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' ||
booking.status === 'AWAITING_PAYMENT' ||
booking.status === 'PAID' ||
booking.paymentStatus === 'PAID';
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT" ||
booking.status === "PAID" ||
booking.paymentStatus === "PAID";
if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
} else if (booking.paymentStatus !== 'PAID') {
.update(bookingId, { paymentStatus: "PAID", status: "PAID" });
} else if (booking.paymentStatus !== "PAID") {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
.update(bookingId, { paymentStatus: "PAID" });
}
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid');
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
@@ -338,7 +345,7 @@ export class BookingBatchService implements OnModuleInit {
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -349,7 +356,11 @@ export class BookingBatchService implements OnModuleInit {
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
if (
result.issues.some(
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
)
) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
@@ -364,9 +375,10 @@ export class BookingBatchService implements OnModuleInit {
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
@@ -375,7 +387,7 @@ export class BookingBatchService implements OnModuleInit {
// ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
@@ -405,7 +417,7 @@ export class BookingBatchService implements OnModuleInit {
destinationStation: true,
route: true,
},
order: { scheduledDepartureDate: 'ASC' },
order: { scheduledDepartureDate: "ASC" },
});
const wagonLengths = await this.loadWagonLengths();
@@ -413,7 +425,7 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue;
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -425,13 +437,15 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
@@ -442,11 +456,15 @@ export class BookingBatchService implements OnModuleInit {
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
throw new BadRequestException('Schedule is no longer active');
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
const s =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
const wagonLengths = await this.loadWagonLengths();
@@ -456,12 +474,18 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
allocationPreview = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
@@ -474,17 +498,23 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
fullyExecutedAt: b.fullyExecutedAt
? b.fullyExecutedAt.toISOString()
: null,
selectedForBatchAt: b.selectedForBatchAt
? b.selectedForBatchAt.toISOString()
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
};
});
@@ -514,11 +544,11 @@ export class BookingBatchService implements OnModuleInit {
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1;
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
@@ -526,7 +556,7 @@ export class BookingBatchService implements OnModuleInit {
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue;
if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
@@ -539,44 +569,51 @@ export class BookingBatchService implements OnModuleInit {
bookings: bucket.items,
});
}
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
date: '',
dateLabel: '',
start: '',
end: '',
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
@@ -597,17 +634,21 @@ export class BookingBatchService implements OnModuleInit {
lengthMeters: number;
}>,
loco: Locomotive | null,
): BatchBoardSchedule['capacity'] {
const allocated = items.filter((i) => i.state === 'ALLOCATED');
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
};
}
@@ -623,51 +664,66 @@ export class BookingBatchService implements OnModuleInit {
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
bookings: items.slice(0, 3),
};
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return 'SELECTED_FOR_BATCH';
private boardState(
booking: Booking,
linked: boolean,
): BatchBoardBookingState {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
return "SELECTED_FOR_BATCH";
}
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
if (booking.status === "EXPIRED") return "EXPIRED";
if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
return "READY";
if (booking.status === "PAID") return "WAITING";
return "PENDING_CONTRACT";
}
// ---- core fill ------------------------------------------------------------
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return;
}
@@ -677,7 +733,7 @@ export class BookingBatchService implements OnModuleInit {
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
await this.setWindow(scheduleId, "FULL");
return;
}
@@ -689,7 +745,12 @@ export class BookingBatchService implements OnModuleInit {
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
budget = await this.preemptForGovernment(
scheduleId,
need,
budget,
wagonLengths,
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -697,7 +758,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
await this.allocate(scheduleId, booking, "gov");
} else {
await this.reserve(booking, scheduleId);
armed = true;
@@ -706,7 +767,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
}
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -732,13 +793,14 @@ export class BookingBatchService implements OnModuleInit {
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.bookingWindowStatus === "OPEN" &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
new Date(a.scheduleDate).getTime() -
new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
@@ -750,15 +812,22 @@ export class BookingBatchService implements OnModuleInit {
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
const budget = await this.remainingCapacity(
schedule,
limits,
wagonLengths,
);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
@@ -779,7 +848,12 @@ export class BookingBatchService implements OnModuleInit {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
t.budget = await this.preemptForGovernment(
t.id,
need,
t.budget,
wagonLengths,
);
if (this.fits(need, t.budget)) {
target = t;
break;
@@ -794,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
await this.allocate(target.id, booking, "gov");
} else {
await this.reserve(booking, target.id);
target.armed = true;
@@ -803,7 +877,7 @@ export class BookingBatchService implements OnModuleInit {
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -813,18 +887,20 @@ export class BookingBatchService implements OnModuleInit {
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
let anySettled = false;
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (expired) {
await this.expire(booking);
@@ -840,17 +916,19 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: true;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
} else if (expired) {
await this.expire(booking);
}
@@ -862,11 +940,13 @@ export class BookingBatchService implements OnModuleInit {
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
void this.trainSchedulingService
.tryAutoWagonAllocation(scheduleId)
.catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
@@ -878,18 +958,20 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.trainScheduleId) {
throw new BadRequestException('Booking has no target schedule to allocate to');
throw new BadRequestException(
"Booking has no target schedule to allocate to",
);
}
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
await this.allocate(booking.trainScheduleId, booking, 'paid');
.update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, "paid");
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
@@ -898,7 +980,10 @@ export class BookingBatchService implements OnModuleInit {
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
* Used for EXPIRED or full-schedule bookings — no re-approval.
*/
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> {
async moveToSchedule(
bookingId: string,
newScheduleId: string,
): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
@@ -907,15 +992,20 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: newScheduleId } });
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Target schedule is not accepting bookings');
if (!schedule)
throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== "OPEN") {
throw new BadRequestException(
"Target schedule is not accepting bookings",
);
}
if (
schedule.originStationId !== booking.originYardId ||
schedule.destinationStationId !== booking.destinationYardId
) {
throw new BadRequestException('Target schedule is not on the booking route');
throw new BadRequestException(
"Target schedule is not on the booking route",
);
}
await this.dataSource.transaction(async (manager) => {
@@ -927,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit {
);
}
const restoredStatus =
booking.status === 'EXPIRED'
booking.status === "EXPIRED"
? booking.isGovernment
? 'APPROVED'
: 'FULLY_EXECUTED'
? "APPROVED"
: "FULLY_EXECUTED"
: booking.status;
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: 'ELIGIBLE',
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -949,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
await this.expire(booking);
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId);
if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
}
// ---- mutations ------------------------------------------------------------
@@ -967,11 +1058,18 @@ export class BookingBatchService implements OnModuleInit {
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
Freight.InvoiceSource.Booking,
booking.id,
deadline,
);
await this.notifier.payNow(booking, deadline);
}
@@ -979,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: 'paid' | 'gov',
reason: "paid" | "gov",
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const exists = await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
const exists =
await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
if (!exists) {
await this.trainScheduleBookingsRepository.createMany(
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
@@ -993,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit {
);
}
await manager.getRepository(Booking).update(booking.id, {
status: reason === 'paid' ? 'PAID' : booking.status,
schedulingStatus: 'SCHEDULED',
status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
@@ -1012,12 +1111,16 @@ export class BookingBatchService implements OnModuleInit {
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
this.notifier.expired(booking);
}
@@ -1035,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit {
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
const allocatedCommercial =
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId);
await this.bookingsRepository.findAllocatedCommercialForSchedule(
scheduleId,
);
// lowest priority first; reserved are cheaper to free than allocated
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
@@ -1052,11 +1157,18 @@ export class BookingBatchService implements OnModuleInit {
manager,
);
await manager.getRepository(Booking).update(victim.id, {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
// Displaced → EXPIRED: close its open invoice too, so a dead booking
// can't still be paid (mirrors `expire()`; enlisted in this txn).
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
manager,
);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, wagonLengths));
@@ -1074,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit {
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
);
}
/** What one booking consumes along all three capacity axes. */
@@ -1161,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit {
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
@@ -1172,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit {
private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
container:
byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
}
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
@@ -1194,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
@@ -1207,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used =
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
@@ -1216,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
private async setWindow(
scheduleId: string,
status: 'OPEN' | 'FULL' | 'CLOSED',
status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> {
await this.dataSource
.getRepository(TrainSchedule)
@@ -1233,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`),
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
);
}, PAYMENT_WINDOW_MS);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
@@ -1242,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit {
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {
if (this.scheduler.doesExist('timeout', name)) {
if (this.scheduler.doesExist("timeout", name)) {
this.scheduler.deleteTimeout(name);
}
} catch {

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
@@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
ImportDjiboutiOperation,
]),
forwardRef(() => BookingsModule),
BillingModule,
NotificationsModule,
LocomotivesModule,
WagonTypesModule,

View File

@@ -1,50 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
@Index(['invoiceId'])
export class WarehouseFeeInvoiceItem extends BaseEntity {
@Column({ name: 'invoice_id', type: 'uuid' })
invoiceId!: string;
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice?: WarehouseFeeInvoice;
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
feeRuleId?: string | null;
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
feeType!: WarehouseFeeType;
@Column({ name: 'description', type: 'varchar', length: 255 })
description!: string;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
quantity!: number;
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
chargeableDays?: number | null;
@Column({ name: 'free_days', type: 'int', nullable: true })
freeDays?: number | null;
}

View File

@@ -1,107 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/**
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
* Owns warehouse fees; links to booking/customer/inventory/location so it can
* connect to the existing payment module without duplicating it.
*/
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
@Index(['invoiceNumber'], { unique: true })
@Index(['bookingId'])
@Index(['inventoryId'])
@Index(['status'])
export class WarehouseFeeInvoice extends BaseEntity {
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
invoiceNumber!: 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: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId?: string | null;
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
zoneId?: string | null;
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
invoiceType!: WarehouseInvoiceType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: WarehouseInvoiceStatus;
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
paidAmount!: number;
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
periodStart?: Date | null;
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
periodEnd?: Date | null;
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
issuedAt?: Date | null;
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
payments!: WarehouseInvoicePayment[];
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
@Injectable()
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
super(repository);
}
}

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@Injectable()
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
super(repository);
}
}

View File

@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {

View File

@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
export interface InventoryInquiryResult {
id: string;
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? { ...base, notes: ILike(`%${search}%`) }
? [
{ ...base, notes: ILike(`%${search}%`) },
{ ...base, grnNumber: ILike(`%${search}%`) },
]
: base;
const items = await this.inventoryRepository.findAll({
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
continue;
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = dto.truckEntrance
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: Number(booking.containerQuantity) || 1,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
quantity,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
);
}
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null;
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: id,
warehouseId: item.warehouseId,
description: reference
? `Release order ${reference} sent to customer`
: 'Release order sent to customer',
description: isTruckLeaving
? reference
? `Exit paper ${reference} generated`
: 'Exit paper generated'
: reference
? `Truck arrival ${reference} registered`
: 'Truck arrival registered',
performedBy: dto.performedBy,
},
manager,
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
inv.volume,
inv.status,
inv.notes,
b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
company.name AS "customerName",
company.tin AS "customerTin",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (!row.grnNumber) {
throw new BadRequestException('GRN number is missing for this inventory item');
}
const html = this.buildGrnDocumentHtml({
grnNumber: row.grnNumber,
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
customerTin: row.customerTin ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
volume: row.volume == null ? null : Number(row.volume),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
receiveSummary: this.extractReceiveSummary(row.notes),
});
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.scheduled_date AS "scheduledDate",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
company.name AS "customerName",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
}
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
reference,
handedOverAt,
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
return this.findById(id);
}
private buildGrnDocumentHtml(data: {
grnNumber: string;
receivedAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
customerTin: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
volume: number | null;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
receiveSummary: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Received Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Goods Received Note</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Goods Received Note</h1>
<div class="subtitle">Warehouse receiving confirmation</div>
</div>
<div class="ref">
GRN Number
<strong>${esc(data.grnNumber)}</strong>
Received: ${esc(receivedAt)}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
</div>
<div class="section-title">Receiving Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Receipt Clause</div>
<div class="clause">
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
</div>
<div class="signatures">
<div class="line">Warehouse receiver name / signature / date</div>
<div class="line">Driver or customer representative name / signature / date</div>
</div>
</body>
</html>`;
}
private buildReleaseDocumentHtml(data: {
reference: string;
issuedAt: Date;
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Gate Clearance / Release Order</title>
<title>Warehouse Release / Exit Paper</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
</div>
<div class="ref">
Document / Release No.
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div>
<div class="section-title">Release Particulars</div>
<table>
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
scheduledDate: Date | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Scheduled Date', fmt(data.scheduledDate)],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
];
const approval = data.customerApproval;
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
inspection, release, and customer approval details for the goods being handed to the customer.
</div>
<div class="section-title">Handover Particulars</div>
<table>
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
let bookingReference = item.booking?.reference;
if (!bookingReference && item.bookingId) {
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
bookingReference = booking?.reference ?? undefined;
}
if (bookingReference) {
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
}
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
}
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) ||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
if (dto.tareWeight === undefined) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
if (grossWeight != null && !dto.gateOutTime) {
throw new BadRequestException('Gate out time is required for truck exit');
}
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
throw new BadRequestException('Gross weight is required for truck exit');
}
const rows = [
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
}
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null;
}
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
}
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
return [
HANDOVER_DOCUMENT_MARKER,
`Handover Reference: ${reference}`,
`Generated At: ${generatedAt.toISOString()}`,
].join('\n');
}
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
const trimmed = notes?.trim();
if (!trimmed) return handoverDocumentNote;
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) {
return `${trimmed}\n\n${handoverDocumentNote}`;
}
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
}
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
if (!notes) return null;
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) return null;
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;

View File

@@ -1,17 +1,24 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import {
WarehouseFeeInvoice,
InvoiceDocumentModel,
InvoiceDocumentService,
} from '../billing/documents/invoice-document.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WarehouseFeeService } from './warehouse-fee.service';
import {
WarehouseFeeInvoiceView,
WarehouseFeeType,
WarehouseInvoiceItemView,
WarehouseInvoiceStatus,
WarehouseInvoiceType,
} from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
} from './warehouse-invoice.types';
interface GenerateOptions {
confirmZero?: boolean;
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;
/** Global statuses that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
/** The warehouse-specific columns derived from the linked inventory item. */
interface InventoryContext {
bookingId: string | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
periodStart: Date | null;
}
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
interface ViewSource {
id: string;
invoiceNumber: string;
companyId: string;
sourceId: string;
type: string;
status: Freight.InvoiceStatus | string;
subtotalAmount: number | string;
taxAmount: number | string;
totalAmount: number | string;
paidAmount: number | string;
balanceAmount: number | string;
currency: string;
issuedAt?: Date | null;
dueAt?: Date | null;
paidAt?: Date | null;
createdAt: Date;
updatedAt: Date;
payments?: Array<{
amount: number | string;
method?: string | null;
reference?: string | null;
paidAt: string;
}> | null;
}
/**
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
* inventoryId`); this service owns only the warehouse-specific concerns —
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
* sealed PDF, and reshaping the global invoice back into the historical
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
* status, and payment math live in billing.
*/
@Injectable()
export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly billing: BillingService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
w.facility_id AS "facilityId",
b.company_id AS "customerId", b.freight_type AS "freightType"
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
b.freight_type AS "freightType"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -75,9 +138,16 @@ export class WarehouseInvoiceService {
);
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException(
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
);
}
// Dedup: only one active (non-cancelled) invoice per inventory item.
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
);
@@ -112,9 +182,7 @@ export class WarehouseInvoiceService {
};
});
const subtotal = items.reduce((s, i) => s + i.amount, 0);
const total = subtotal; // tax model can be layered on later
const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.');
}
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = billingCurrency;
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType,
description: it.description,
quantity: it.quantity,
unitRate: it.unitRate,
amount: it.amount,
currency: it.currency,
metadata: {
feeRuleId: it.feeRuleId ?? null,
chargeableDays: it.chargeableDays ?? null,
freeDays: it.freeDays ?? null,
},
}));
const invoice = await this.invoiceRepository.create({
invoiceNumber: await this.nextInvoiceNumber(),
bookingId: item.bookingId ?? null,
customerId: item.customerId ?? null,
inventoryId,
facilityId: item.facilityId ?? null,
warehouseId: item.warehouseId ?? null,
yardId: item.yardId ?? null,
zoneId: item.zoneId ?? null,
invoiceType,
status: 'ISSUED',
subtotalAmount: subtotal,
taxAmount: 0,
totalAmount: total,
paidAmount: 0,
balanceAmount: total,
currency,
periodStart: item.arrivedAt ?? null,
periodEnd,
issuedAt: now,
payments: [],
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
const invoice = await this.billing.generateInvoice({
source: SOURCE,
sourceId: inventoryId,
type: invoiceType,
companyId: item.companyId,
companyProfileId: item.companyProfileId,
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
for (const it of items) {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day. */
private async nextInvoiceNumber(): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const prefix = `WHF-${ymd}-`;
const [row] = await this.dataSource.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, '0')}`;
const detail = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(detail);
return detail;
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
const ctx = await this.getInventoryContext(invoice.sourceId);
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
const items = invoice.lines.map((l) => this.lineToItem(l));
return { ...this.buildView(invoice, ctx), ...details, items };
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]);
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
}
async findAll(
filter: Partial<
Pick<
WarehouseFeeInvoiceView,
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
>
>,
): Promise<WarehouseFeeInvoiceView[]> {
const conditions: string[] = [];
const params: unknown[] = [];
const add = (sql: (p: string) => string, value: unknown) => {
params.push(value);
conditions.push(sql(`$${params.length}`));
};
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
}
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
}
// ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
return updated as WarehouseFeeInvoice;
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('A paid invoice cannot be cancelled.');
}
await this.billing.cancelInvoice(id);
return this.findById(id);
}
/** Record a payment against the invoice and sync status (links to existing payment flow). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
const paidAmount = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.totalAmount);
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
const fullyPaid = paidAmount >= total;
const payments = [
...(invoice.payments ?? []),
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
];
const updated = await this.invoiceRepository.update(id, {
paidAmount: Math.round(paidAmount * 100) / 100,
balanceAmount: balance,
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
/** Record a payment against the invoice (delegates settlement to billing). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
amount: dto.amount,
method: dto.method ?? null,
reference: dto.reference ?? null,
metadata:
dto.driverName || dto.driverPhone
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
: null,
});
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
const detail = await this.findById(id);
await this.notifyWarehouseFeePayment(detail, dto);
return detail;
}
/**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path
* notifies inline (and carries driver details from the request), so this only
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
* double-notifying a counter payment that already sent its SMS.
*/
@OnEvent('warehouse.invoice.paid')
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
if (!payload.paymentId) return;
const detail = await this.findById(payload.invoiceId);
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
);
return blocking[0] ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
@@ -286,12 +350,218 @@ export class WarehouseInvoiceService {
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
// ── Internal: loading & projection ─────────────────────────────────────────
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
const [row] = await this.dataSource.query(
`SELECT 1
FROM freight.invoices
WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
LIMIT 1`,
[SOURCE, inventoryId, ACTIVE_STATUSES],
);
return Boolean(row);
}
/**
* Project warehouse-source global invoices into the historical view, joined to
* their inventory item for the typed FKs. Powers every list/filter read.
*/
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
const rows = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
i.source_id AS "sourceId", i.type, i.status,
i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
i.balance_amount AS "balanceAmount", i.currency, i.payments,
i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
i.created_at AS "createdAt", i.updated_at AS "updatedAt",
inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
ORDER BY i.created_at DESC`,
[...params, SOURCE],
);
return (rows as Array<ViewSource & InventoryContext>).map((row) =>
this.buildView(row, {
bookingId: row.bookingId ?? null,
facilityId: row.facilityId ?? null,
warehouseId: row.warehouseId ?? null,
yardId: row.yardId ?? null,
zoneId: row.zoneId ?? null,
periodStart: row.periodStart ?? null,
}),
);
}
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
const status = this.toWarehouseStatus(inv.status);
return {
id: inv.id,
invoiceNumber: inv.invoiceNumber,
bookingId: ctx.bookingId,
customerId: inv.companyId ?? null,
inventoryId: inv.sourceId,
facilityId: ctx.facilityId,
warehouseId: ctx.warehouseId,
yardId: ctx.yardId,
zoneId: ctx.zoneId,
invoiceType: inv.type as WarehouseInvoiceType,
status,
subtotalAmount: Number(inv.subtotalAmount),
taxAmount: Number(inv.taxAmount),
totalAmount: Number(inv.totalAmount),
paidAmount: Number(inv.paidAmount),
balanceAmount: Number(inv.balanceAmount),
currency: inv.currency,
periodStart: ctx.periodStart,
// No standalone period column once centralized: the charge window ends at
// issuance, so `issuedAt` is the period end.
periodEnd: inv.issuedAt ?? null,
issuedAt: inv.issuedAt ?? null,
dueDate: inv.dueAt ?? null,
paidAt: inv.paidAt ?? null,
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
payments: (inv.payments ?? []).map((p) => ({
amount: Number(p.amount),
method: p.method ?? null,
reference: p.reference ?? null,
paidAt: p.paidAt,
})),
notes: null,
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
};
}
private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
const meta = (line.metadata ?? {}) as {
feeRuleId?: string | null;
chargeableDays?: number | null;
freeDays?: number | null;
};
return {
feeRuleId: meta.feeRuleId ?? null,
feeType: line.chargeType as WarehouseFeeType,
description: line.description ?? '',
quantity: Number(line.quantity),
unitRate: Number(line.unitRate),
amount: Number(line.amount),
currency: line.currency,
chargeableDays: meta.chargeableDays ?? null,
freeDays: meta.freeDays ?? null,
};
}
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
switch (status) {
case Freight.InvoiceStatus.Draft:
return 'DRAFT';
case Freight.InvoiceStatus.PartiallyPaid:
return 'PARTIALLY_PAID';
case Freight.InvoiceStatus.Paid:
return 'PAID';
case Freight.InvoiceStatus.Cancelled:
case Freight.InvoiceStatus.Refunded:
return 'CANCELLED';
default:
// Issued / Pending / Overdue → an issued, still-owed invoice.
return 'ISSUED';
}
}
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
switch (status) {
case 'DRAFT':
return Freight.InvoiceStatus.Draft;
case 'PARTIALLY_PAID':
return Freight.InvoiceStatus.PartiallyPaid;
case 'PAID':
return Freight.InvoiceStatus.Paid;
case 'CANCELLED':
return Freight.InvoiceStatus.Cancelled;
default:
return Freight.InvoiceStatus.Issued;
}
}
/** Map a warehouse fee invoice view onto the shared document model. */
private toDocumentModel(
invoice: WarehouseFeeInvoiceDetail,
kind: 'INVOICE' | 'RECEIPT',
): InvoiceDocumentModel {
const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
return {
kind,
title: 'Warehouse Fee',
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
{ label: 'Customer', value: invoice.customerName ?? null },
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
{
label: 'Yard / Zone',
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
},
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
{
label: 'Payment',
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
},
],
categoryHeader: 'Fee type',
lines: invoice.items.map((item) => ({
description: item.description ?? null,
category: item.feeType ?? null,
quantity: item.quantity ?? item.chargeableDays ?? 0,
unitRate: item.unitRate,
amount: item.amount,
currency: item.currency ?? invoice.currency,
})),
totals: [
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
{ label: 'Tax', amount: Number(invoice.taxAmount) },
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
{ label: 'Paid', amount: Number(invoice.paidAmount) },
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
],
};
}
/** Warehouse-specific display details, derived from the linked inventory item. */
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
zone.name AS "zoneName"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -319,14 +583,21 @@ export class WarehouseInvoiceService {
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id, invoice.status],
[invoice.sourceId],
);
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
const clearanceStatus = row?.releaseDate
? 'RELEASE ISSUED'
: fullyPaid
? 'FEE PAID - READY FOR RELEASE'
: 'PENDING PAYMENT';
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
clearanceStatus,
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
return {
bookingId: row?.bookingId ?? null,
facilityId: row?.facilityId ?? null,
warehouseId: row?.warehouseId ?? null,
yardId: row?.yardId ?? null,
zoneId: row?.zoneId ?? null,
periodStart: row?.periodStart ?? null,
};
}
// ── Notifications ──────────────────────────────────────────────────────────
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id],
[inventoryId],
);
return {
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Customer</span>${esc(details.customerName)}</div>
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,88 @@
/**
* Public shapes for warehouse fee invoices.
*
* Warehouse fee invoices are no longer a standalone table — they are global
* `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the
* central {@link BillingService}. These types preserve the warehouse-facing API
* contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines +
* inventory context) back into the historical `WarehouseFeeInvoice` JSON so the
* portal/backoffice stay untouched.
*/
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/** A billed warehouse fee line, projected from a global `InvoiceLine`. */
export interface WarehouseInvoiceItemView {
feeRuleId: string | null;
feeType: WarehouseFeeType;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
chargeableDays: number | null;
freeDays: number | null;
}
/**
* The warehouse-facing invoice header — same field set the old
* `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The
* typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the
* charge `period` are derived from the linked inventory item; `customerId` is the
* billed company; `invoiceType` is the invoice `type`.
*/
export interface WarehouseFeeInvoiceView {
id: string;
invoiceNumber: string;
bookingId: string | null;
customerId: string | null;
inventoryId: string;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
invoiceType: WarehouseInvoiceType;
status: WarehouseInvoiceStatus;
subtotalAmount: number;
taxAmount: number;
totalAmount: number;
paidAmount: number;
balanceAmount: number;
currency: string;
periodStart: Date | null;
periodEnd: Date | null;
issuedAt: Date | null;
dueDate: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
payments: WarehouseInvoicePayment[];
notes: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,101 +1,23 @@
import { existsSync } from 'fs';
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
constructor(private readonly pdf: PdfRenderService) {}
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
/**
* Render the gate-clearance release document to PDF via the shared renderer,
* falling back to the release-specific hand-built layout when Chromium is
* unavailable.
*/
htmlToPdfBuffer(html: string): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label: 'Warehouse release',
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
});
}
private htmlToBasicPdfBuffer(html: string): Buffer {

View File

@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
@@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseRulesController } from './warehouse-rules.controller';
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport,
WarehouseAllocationRule,
WarehouseFeeRule,
WarehouseFeeInvoice,
WarehouseFeeInvoiceItem,
]),
BillingModule,
DocumentsModule,
FilesModule,
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionRepository,
WarehouseAllocationRuleRepository,
WarehouseFeeRuleRepository,
WarehouseFeeInvoiceRepository,
WarehouseFeeInvoiceItemRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,

View File

@@ -0,0 +1,12 @@
import type Vorpal from "vorpal";
import type { CommandContext } from "./types";
import { registerSeedTestContracts } from "./seed-test-contracts.cmd";
import { registerSeedTestSchedules } from "./seed-test-schedules.cmd";
import { registerSeedTestCompany } from "./seed-test-company.cmd";
export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void {
registerSeedTestContracts(vorpal, ctx);
registerSeedTestSchedules(vorpal, ctx);
registerSeedTestCompany(vorpal, ctx);
}

View File

@@ -0,0 +1,85 @@
import type Vorpal from "vorpal";
import { DataSource } from "typeorm";
import type { CommandContext } from "./types";
import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity";
import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity";
import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity";
export function registerSeedTestCompany(
vorpal: Vorpal,
ctx: CommandContext,
): void {
vorpal
.command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user")
.option("--name <name>", "Company name (default: Test Company)")
.option("--email <email>", "Company email (default: company@test.com)")
.option("--tin <tin>", "Tax ID (default: auto-generated TSTxxxxx)")
.action(async function (this: any, args: any) {
const { app } = ctx;
const ds = app.get(DataSource);
const raw = await ds.query(
`SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`,
);
let nextTinNum = 1;
if (raw.length > 0) {
const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10);
if (!isNaN(num)) nextTinNum = num + 1;
}
const name = args.options?.name ?? "Test Company";
const email = args.options?.email ?? "company@test.com";
const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`;
const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`;
const existing = await ds.getRepository(Company).findOne({ where: { tin } });
if (existing) {
this.log(`Company with TIN ${tin} already exists (${existing.name})`);
return;
}
const company = await ds.getRepository(Company).save(
ds.getRepository(Company).create({
name,
type: CompanyType.Customer,
kind: CompanyKind.Commercial,
status: CompanyStatus.Active,
tin,
country: "Ethiopia",
nationality: CompanyNationality.Ethiopian,
email,
phone: "+251911000000",
address: "Test Address",
}),
);
this.log(` Created company: ${company.name} (TIN: ${tin})`);
for (const type of [ProfileType.importer, ProfileType.exporter]) {
await ds.getRepository(CompanyProfile).save(
ds.getRepository(CompanyProfile).create({
companyId: company.id,
type,
reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`,
status: ProfileStatus.Active,
}),
);
this.log(` Created ${type} profile (approved)`);
}
await ds.getRepository(ExternalProfile).save(
ds.getRepository(ExternalProfile).create({
userId,
companyId: company.id,
firstName: "Test",
lastName: "User",
isPrimaryContact: true,
activeProfileType: ProfileType.importer,
onboardingCompleted: true,
onboardingStep: "done",
}),
);
this.log(` Created external profile: Test User (userId: ${userId})`);
this.log(`\nDone — login with email "${email}" and password "password"`);
});
}

View File

@@ -0,0 +1,330 @@
import type Vorpal from "vorpal";
import { DataSource } from "typeorm";
import type { CommandContext } from "./types";
import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity";
import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity";
import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity";
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity";
import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity";
import { Rate } from "../../modules/rule-engine/entities/rate.entity";
import { Contract } from "../../modules/contracts/entities/contract.entity";
import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity";
import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity";
import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity";
export function registerSeedTestContracts(
vorpal: Vorpal,
ctx: CommandContext,
): void {
vorpal
.command("seed:test-contracts", "Generate test contracts with companies and all deps")
.option("-n, --count <n>", "Number of contracts to create (default: 4)")
.option("--status <statuses>", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)")
.option("--freight <types>", "Freight types: CONTAINER,BULK (default: both)")
.option("--direction <dirs>", "Trade directions: IMPORT,EXPORT (default: both)")
.option("--company <name>", "Only create contracts for company matching name/TIN")
.action(async function (this: any, args: any) {
const { app } = ctx;
const ds = app.get(DataSource);
const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10)));
const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE")
.split(",").map((s: string) => s.trim()).filter(Boolean);
const freightList = (args.options?.freight ?? "CONTAINER,BULK")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "CONTAINER" || s === "BULK");
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
const companyFilter = args.options?.company as string | undefined;
if (freightList.length === 0 || directionList.length === 0) {
this.log("error: at least one freight type and trade direction required");
return;
}
this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`);
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
const yardByCode = new Map(yards.map((y) => [y.code, y]));
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
if (!djibouti || !addis) {
this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded");
return;
}
const serviceTypes = await ds
.getRepository(ServiceType)
.find({ where: { isActive: true } });
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
const railContainer = stByCode.get("RAIL_CONTAINER");
const railBulk = stByCode.get("RAIL_BULK");
if (!railContainer && !railBulk) {
this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded");
return;
}
const cargoTypes = await ds
.getRepository(CargoType)
.find({ where: { isActive: true } });
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
const grain = cargoByCode.get("GRAIN");
const sugar = cargoByCode.get("SUGAR");
const fertilizer = cargoByCode.get("FERTILIZER");
const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } });
const companyRepo = ds.getRepository(Company);
let companies = await companyRepo.find({});
if (companyFilter) {
companies = companies.filter(
(c) =>
c.name.toLowerCase().includes(companyFilter.toLowerCase()) ||
c.tin.includes(companyFilter),
);
}
if (companies.length === 0) {
this.log("No existing companies found — seeding test companies...");
companies = await seedTestCompanies(ds, (msg) => this.log(msg));
} else {
this.log(`Using ${companies.length} existing companies from DB`);
}
const contractRepo = ds.getRepository(Contract);
const maxRaw = await ds.query(
`SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`,
);
let nextRef = 1;
if (maxRaw.length > 0) {
const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10);
if (!isNaN(num)) nextRef = num + 1;
}
for (let i = 0; i < count; i++) {
const statusIdx = i % statusList.length;
const ftIdx = i % freightList.length;
const dirIdx = i % directionList.length;
const companyIdx = i % companies.length;
const status = statusList[statusIdx];
const freightType = freightList[ftIdx];
const direction = directionList[dirIdx];
const company = companies[companyIdx];
const profile = await ds.getRepository(CompanyProfile).findOne({
where: {
companyId: company.id,
type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter,
},
});
if (!profile) continue;
const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`;
const serviceTypeId =
freightType === "BULK" && railBulk
? railBulk.id
: railContainer
? railContainer.id
: serviceTypes[0].id;
const originId = direction === "IMPORT" ? djibouti.id : addis.id;
const destId = direction === "IMPORT" ? addis.id : djibouti.id;
const contract = contractRepo.create({
reference: ref,
companyId: company.id,
companyProfileId: profile.id,
contractKind: "ONE_TIME" as const,
tradeDirection: direction,
freightType,
serviceTypeId,
paymentCurrency: "USD",
customsClearingEnabled: false,
equipmentReturn: "without_return",
status,
versionNumber: 1,
});
const saved = await contractRepo.save(contract);
await ds.getRepository(ContractRoute).save(
ds.getRepository(ContractRoute).create({
contractId: saved.id,
originYardId: originId,
destinationYardId: destId,
sortOrder: 1,
}),
);
if (freightType === "CONTAINER") {
for (const size of ["20FT", "40FT"] as const) {
await ds.getRepository(ContractCargoScope).save(
ds.getRepository(ContractCargoScope).create({
contractId: saved.id,
containerSize: size,
}),
);
}
} else {
const bulkCargo = grain || sugar || fertilizer;
if (bulkCargo) {
await ds.getRepository(ContractCargoScope).save(
ds.getRepository(ContractCargoScope).create({
contractId: saved.id,
cargoTypeId: bulkCargo.id,
quantityCap: 10000,
}),
);
}
}
const matchingRates = rates.filter((r) => {
if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false;
if (r.appliesTo === "BULK" && freightType !== "BULK") return false;
if (r.tradeDirection && r.tradeDirection !== direction) return false;
return r.status === "LIVE" && r.trigger === "ALWAYS";
});
const seen = new Set<string>();
for (const rate of matchingRates.slice(0, 3)) {
const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`;
if (seen.has(sig)) continue;
seen.add(sig);
await ds.getRepository(ContractRateSnapshot).save(
ds.getRepository(ContractRateSnapshot).create({
contractId: saved.id,
rateId: rate.id,
rateCode: rate.rateType,
unitPrice: Number(rate.rateValue),
unitOfMeasure: rate.rateUnit,
currency: rate.currency ?? "USD",
containerSize: freightType === "CONTAINER" ? "20FT" : null,
isSurcharge: rate.trigger !== "ALWAYS",
conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null,
}),
);
}
this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`);
}
this.log(`Done — ${count} new contracts created`);
});
}
interface CompanySeed {
name: string;
tin: string;
profiles: Array<{ type: ProfileType; reference: string }>;
externalProfile: { userId: string; firstName: string; lastName: string };
}
const TEST_COMPANIES: CompanySeed[] = [
{
name: "Test Importer Co.", tin: "TST000001",
profiles: [
{ type: ProfileType.importer, reference: "TST-IM-001" },
{ type: ProfileType.exporter, reference: "TST-EX-001" },
],
externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" },
},
{
name: "Test Exporter Ltd.", tin: "TST000002",
profiles: [
{ type: ProfileType.importer, reference: "TST-IM-002" },
{ type: ProfileType.exporter, reference: "TST-EX-002" },
],
externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" },
},
{
name: "Bulk Commodities PLC", tin: "TST000003",
profiles: [
{ type: ProfileType.importer, reference: "TST-IM-003" },
{ type: ProfileType.exporter, reference: "TST-EX-003" },
],
externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" },
},
{
name: "Hazardous Logistics Inc.", tin: "TST000004",
profiles: [
{ type: ProfileType.importer, reference: "TST-IM-004" },
{ type: ProfileType.exporter, reference: "TST-EX-004" },
],
externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" },
},
];
async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise<Company[]> {
const companyRepo = ds.getRepository(Company);
const profileRepo = ds.getRepository(CompanyProfile);
const extProfileRepo = ds.getRepository(ExternalProfile);
const result: Company[] = [];
for (const seed of TEST_COMPANIES) {
let company = await companyRepo.findOne({ where: { tin: seed.tin } });
if (!company) {
company = await companyRepo.save(
companyRepo.create({
name: seed.name,
type: CompanyType.Customer,
kind: CompanyKind.Commercial,
status: CompanyStatus.Active,
tin: seed.tin,
country: "Ethiopia",
nationality: CompanyNationality.Ethiopian,
email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`,
phone: "+251911000001",
}),
);
log(` Created company: ${seed.name}`);
} else {
log(` Company already exists: ${seed.name}`);
}
for (const p of seed.profiles) {
const existing = await profileRepo.findOne({
where: { companyId: company.id, type: p.type },
});
if (!existing) {
await profileRepo.save(
profileRepo.create({
companyId: company.id,
type: p.type,
reference: p.reference,
status: ProfileStatus.Active,
}),
);
log(` Created ${p.type} profile: ${p.reference}`);
}
}
const ext = seed.externalProfile;
const existingExt = await extProfileRepo.findOne({
where: { companyId: company.id, userId: ext.userId },
});
if (!existingExt) {
await extProfileRepo.save(
extProfileRepo.create({
userId: ext.userId,
companyId: company.id,
firstName: ext.firstName,
lastName: ext.lastName,
isPrimaryContact: true,
onboardingCompleted: true,
}),
);
log(` Created external profile: ${ext.firstName} ${ext.lastName}`);
}
result.push(company);
}
return result;
}

View File

@@ -0,0 +1,243 @@
import type Vorpal from "vorpal";
import { DataSource } from "typeorm";
import { WagonStatus } from "@edr/types";
import type { CommandContext } from "./types";
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
import { Route } from "../../modules/routes/entities/route.entity";
import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity";
import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity";
import { Wagon } from "../../modules/wagons/entities/wagon.entity";
import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity";
import { TrainSet } from "../../modules/train-sets/entities/train-set.entity";
import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity";
import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity";
import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity";
async function nextSequence(ds: DataSource, pattern: string): Promise<number> {
const like = pattern.replace(/\*/g, "%");
const raw = await ds.query(
`SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`,
[like.replace(/%/g, "") + "%"],
);
if (raw.length === 0) return 1;
const ref: string = raw[0].train_number;
const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10);
return isNaN(num) ? 1 : num + 1;
}
async function nextRouteSeq(ds: DataSource, prefix: string): Promise<number> {
const raw = await ds.query(
`SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`,
[prefix + "%"],
);
if (raw.length === 0) return 1;
const num = parseInt(raw[0].name.replace(prefix, ""), 10);
return isNaN(num) ? 1 : num + 1;
}
async function nextWagonSeq(ds: DataSource, prefix: string): Promise<number> {
const raw = await ds.query(
`SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`,
[prefix + "%"],
);
if (raw.length === 0) return 1;
const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10);
return isNaN(num) ? 1 : num + 1;
}
export function registerSeedTestSchedules(
vorpal: Vorpal,
ctx: CommandContext,
): void {
vorpal
.command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking")
.option("-n, --count <n>", "Number of schedules to create (default: 3)")
.option("--direction <dirs>", "IMPORT,EXPORT (default: both)")
.option("--status <statuses>", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)")
.option("--days-ahead <n>", "Days from now for departure (default: 3)")
.action(async function (this: any, args: any) {
const { app } = ctx;
const ds = app.get(DataSource);
const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10)));
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
const statusList = (args.options?.status ?? "SCHEDULED")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED");
const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10));
if (directionList.length === 0 || statusList.length === 0) {
this.log("error: at least one direction and status required");
return;
}
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y]));
const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti");
const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia");
if (!djibouti || !addis) {
this.log("error: need at least one Djibouti and one Ethiopia yard");
return;
}
const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } });
if (wagonTypes.length === 0) {
this.log("error: no wagon types found — seed reference data first");
return;
}
const wagonType = wagonTypes[0];
const wagonCapacity = Number(wagonType.capacityTons) || 70;
const wagonLength = Number(wagonType.lengthMeters) || 14;
const tareWeight = Number(wagonType.tareWeightTons) || 14;
const locomotiveRepo = ds.getRepository(Locomotive);
const scheduleRepo = ds.getRepository(TrainSchedule);
const trainSetRepo = ds.getRepository(TrainSet);
const wagonRepo = ds.getRepository(Wagon);
const routeRepo = ds.getRepository(Route);
const milestoneRepo = ds.getRepository(RouteMilestone);
let nextTrainNum = await nextSequence(ds, "TST-SCH-*");
const routePrefix = "TST-RTE-";
let nextRouteNum = await nextRouteSeq(ds, routePrefix);
const now = new Date();
const travelHours = 11;
const intermediateYards = yards.filter(
(y) => y.id !== djibouti.id && y.id !== addis.id,
);
let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } });
if (!loco) {
loco = await locomotiveRepo.save(
locomotiveRepo.create({
code: "TST-LOCO-01",
name: "Test Locomotive",
locomotiveType: "DIESEL",
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: "AVAILABLE",
currentYardId: djibouti.id,
}),
);
}
for (let i = 0; i < count; i++) {
const seq = nextTrainNum + i;
const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`;
const dir = directionList[i % directionList.length];
const status = statusList[i % statusList.length];
const isDispatched = status === "DISPATCHED";
const originYard = dir === "IMPORT" ? djibouti : addis;
const destYard = dir === "IMPORT" ? addis : djibouti;
const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`;
const departure = new Date(now);
departure.setDate(departure.getDate() + daysAhead + i);
departure.setHours(7, 0, 0, 0);
const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000);
const route = await routeRepo.save(
routeRepo.create({
name: routeName,
originYardId: originYard.id,
destinationYardId: destYard.id,
isActive: true,
}),
);
await milestoneRepo.save(
milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }),
);
for (const [mi, y] of intermediateYards.entries()) {
await milestoneRepo.save(
milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }),
);
}
await milestoneRepo.save(
milestoneRepo.create({
routeId: route.id,
yardId: destYard.id,
sequenceNo: (intermediateYards.length + 1) * 2,
}),
);
const totalWagonWeight = 4 * (tareWeight + 20);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: loco.id,
totalWeightTons: totalWagonWeight,
totalLengthMeters: wagonLength * 4,
wagonCount: 4,
status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED",
}),
);
await ds.getRepository(TrainSetLocomotive).save(
ds.getRepository(TrainSetLocomotive).create({
trainSetId: trainSet.id,
locomotiveId: loco.id,
sequenceNo: 0,
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
routeId: route.id,
originStationId: originYard.id,
destinationStationId: destYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: isDispatched ? departure : null,
status,
trainNumber,
direction: dir,
maxWagons: 53,
bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN",
}),
);
const wagonPrefix = `${trainNumber}-W`;
let nextWagon = await nextWagonSeq(ds, wagonPrefix);
for (let w = 0; w < 4; w++) {
const ws = nextWagon + w;
const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`;
const wagon = wagonRepo.create({
wagonNumber,
wagonTypeId: wagonType.id,
currentYardId: originYard.id,
currentTrainScheduleId: schedule.id,
tareWeight,
maxPayloadWeight: wagonCapacity,
status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available,
notes: "Test seed wagon",
});
const saved = await wagonRepo.save(wagon as any);
const physicalWagon = Array.isArray(saved) ? saved[0] : saved;
await ds.getRepository(TrainSetWagon).save(
ds.getRepository(TrainSetWagon).create({
trainSetId: trainSet.id,
wagonTypeId: wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: w + 1,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: 20,
status: isDispatched ? "DEPARTED" : "PLANNED",
}),
);
}
this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label}${destYard.label})`);
}
this.log(`Done — ${count} new train schedules created`);
});
}

View File

@@ -0,0 +1,5 @@
import type { INestApplicationContext } from "@nestjs/common";
export type CommandContext = {
app: INestApplicationContext;
};

View File

@@ -0,0 +1,36 @@
import "reflect-metadata";
import { config } from "dotenv";
config();
import Vorpal from "vorpal";
import { registerCommands } from "./cmds/index";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../app.module";
const vorpal = new Vorpal();
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
try {
registerCommands(vorpal, { app });
const args = process.argv.slice(2);
if (args.length > 0) {
await vorpal.exec(args.join(" "));
} else {
vorpal.parse(process.argv);
}
} finally {
await app.close();
}
}
main().catch((err) => {
console.error("Script failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,142 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const BOOKING_REFS = [
'WH-EXP-RCV-001',
'WH-EXP-RCV-002',
'WH-EXP-RCV-003',
'WH-EXP-RCV-004',
'WH-EXP-RCV-005',
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const dataSource = app.get(DataSource);
const yardRepo = dataSource.getRepository(Yard);
const serviceTypeRepo = dataSource.getRepository(ServiceType);
const cargoTypeRepo = dataSource.getRepository(CargoType);
const containerTypeRepo = dataSource.getRepository(ContainerType);
const bookingRepo = dataSource.getRepository(Booking);
const bookingContainerRepo = dataSource.getRepository(BookingContainer);
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ??
(await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { isActive: true } }));
const missing = [
!originYard ? 'MOJO/Ethiopia origin yard' : '',
!destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '',
!serviceType ? 'active service type without first mile' : '',
!containerType ? 'active container type' : '',
].filter(Boolean);
if (missing.length) {
throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`);
}
let created = 0;
let skipped = 0;
const now = Date.now();
for (const [index, reference] of BOOKING_REFS.entries()) {
const existing = await bookingRepo.findOne({ where: { reference } });
if (existing) {
skipped += 1;
continue;
}
const containerQuantity = index === 4 ? 2 : 1;
const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000;
const scheduledDate = new Date(now + index * 60 * 60_000);
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'PAID',
paymentStatus: 'PAID',
scheduledDate,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: 'CONTAINER',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`,
cargoTotalWeightVgm: weightKg,
schedulingStatus: 'NOT_SCHEDULED',
}),
);
await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType!.id,
containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`,
containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code,
quantity: containerQuantity,
hazardousQuantity: 0,
reeferQuantity: 0,
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
isOverweight: false,
}),
);
const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } });
if (inventory) {
throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`);
}
created += 1;
}
console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`);
console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`);
console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.');
} finally {
await app.close();
}
}
main().catch((error) => {
console.error('Warehouse export receive-ready seed failed:', error);
process.exit(1);
});

View File

@@ -7,6 +7,7 @@
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo",
"preserveWatchOutput": true,
"module": "node16",
"moduleResolution": "node16"
},

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5183",
"dev": "vite --port 5183 --clearScreen false",
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
"build": "vite build",
"preview": "vite preview --port 5183",

View File

@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
);
}
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
handoverReference ? `Handover ${handoverReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Handover reference" value={handoverReference || '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
import {
ActionIcon,
Alert,
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void;
}
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}
interface Location {
warehouseId: string;
yardId: string;
@@ -620,11 +659,13 @@ function EligibleTab({
const { toast } = useToast();
const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
api.warehouses.eligibleBookings.queryOptions({
input: { direction },
enabled,
}),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => {
@@ -782,10 +823,24 @@ function EligibleTab({
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(form);
setLockedTruckFields(lockedFields);
setTruckForm(normalizedForm);
setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
});
setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true);
};
@@ -798,18 +853,6 @@ function EligibleTab({
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
};
const loadPassedExport = async () => {
try {
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
});
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
}
};
return (
<Stack gap="sm" mt="sm">
@@ -828,18 +871,6 @@ function EligibleTab({
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text>
<Group gap="xs">
{direction === 'EXPORT' && (
<Button
size="compact-sm"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
loading={loadPassed.isPending}
onClick={loadPassedExport}
>
Load Passed Export Items
</Button>
)}
<Button
size="compact-sm"
color={direction === 'EXPORT' ? 'edr-green' : undefined}
@@ -849,7 +880,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
>
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
</Button>
<Button
size="compact-sm"
@@ -858,7 +889,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([...selected])}
>
Receive Selected
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
</Button>
</Group>
</Group>
@@ -1000,7 +1031,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
</Button>
)}
</Table.Td>
@@ -1015,7 +1046,7 @@ function EligibleTab({
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Receive to Warehouse"
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
centered
size="lg"
>
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
</Table.Th>
)}
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
</Table.Td>
)}
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
booking: row.bookingId
? {
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
try {
const response = await warehouseService.downloadHandoverDocument(row.id);
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
}
};
const openReleaseDocument = async (row: ImportUnloadedItem) => {
setBusyId(row.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(row.id);
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th>
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Truck Arrival
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Dispatch
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
loading={busyId === r.id}
onClick={() => openReleaseDocument(r)}
>
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
Handover
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
onChanged?: () => void;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
const { data: eligibleRows = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: 'EXPORT' },
enabled,
}),
);
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
};
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
const bookingReference = item?.booking?.reference;
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
return '';
};
const lineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
};
const splitContainerNumbers = (value: string | null | undefined) =>
(value ?? '')
.split(/[,;\n]+/)
.map((number) => number.trim())
.filter(Boolean);
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
};
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
const savedNumbers = splitContainerNumbers(savedContainerNumber);
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => {
if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
const inspection = parseInspectionNote(item?.notes);
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
const handleSubmit = async () => {
if (!item) return;
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
return;
}
if (weightMismatch) {
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
if (isExitStep && weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
});
return;
}
const pdfWindow = window.open('', '_blank');
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
try {
const released = await releaseMutation.mutateAsync({
id: item.id,
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
if (!isExitStep) {
toast({
title: 'Truck arrival saved',
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
return;
}
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Register the customer truck and driver at arrival, record tare weight, then record gross
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
equal gross weight minus tare weight.
</Text>
{isExitStep ? (
<Text size="sm">
Record the truck leaving time and gross weight. The system recorded net weight is locked,
and the exit paper is generated only when it equals gross weight minus tare weight.
</Text>
) : (
<Text size="sm">
Register the customer truck and driver at arrival, then save gate in time and tare weight.
Reopen this form when the truck is leaving to complete the exit weighing.
</Text>
)}
</Alert>
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<Select
label="Registered first / last-mile truck"
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
/>
))}
</SimpleGrid>
</Stack>
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Save Truck Arrival & View Exit Paper
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>
</Stack>

View File

@@ -1,13 +1,17 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import {
getNextInventoryAction,
type InventoryAction,
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({
items,
busyId,
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
return (
<Table.Tr key={item.id}>
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
</Text>
)}
</Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
</Tooltip>
)}
{onHandoverDocument && canGenerateHandover && (
<Tooltip label="Generate customer handover document" withArrow>
<Tooltip
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
withArrow
>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} />
</ActionIcon>

View File

@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
const escapePdfText = (value: string) =>
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const money = (amount: unknown, currency = 'USD') =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const fmtDate = (value: unknown) => {
if (!value) return '-';
const date = new Date(value as string | Date);
@@ -96,12 +93,6 @@ const textOp = (
color = '0 0 0',
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
lineOp(60, 242, 535, 242),
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
buildCircularSeal(452, 155, label),
];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
return new Blob([pdf], { type: 'application/pdf' });
}
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [
{ text: item.description, bold: true, align: 'center' as const },
{
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
yGap: 13,
align: 'center' as const,
},
]),
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
];
const authorizationOps = [
...buildAuthorizationBand('PAID'),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
const invoiceOps = [
lineOp(60, 242, 535, 242),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
}
const firstText = (...values: Array<unknown>) => {
for (const value of values) {
if (value !== null && value !== undefined && String(value).trim()) return String(value);

View File

@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)

View File

@@ -1,6 +1,6 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/**
* Scroll to the element whose `id` matches the URL hash. Retries for a short
* window so it still lands on sections that mount after an async fetch (there is
* no router-level hash handling). Deep-link targets give a card an `id`.
*/
export function useScrollToHash(): void {
const { hash } = useLocation();
useEffect(() => {
if (!hash) return;
const id = decodeURIComponent(hash.slice(1));
let tries = 0;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (tries++ < 20) timer = setTimeout(tick, 100);
};
timer = setTimeout(tick, 100);
return () => clearTimeout(timer);
}, [hash]);
}

View File

@@ -50,6 +50,7 @@ import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
@@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const {
data: booking,
isLoading,
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
<Box id="warehouse-payments">
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Box>
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -1,5 +1,6 @@
import { Button, Card } from '@mantine/core';
import { PackageSearch } from 'lucide-react';
import { useState } from 'react';
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
import { PackageSearch, Truck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page';
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ExportWarehouseFlowPage() {
const navigate = useNavigate();
const [receiveOpen, setReceiveOpen] = useState(false);
return (
<PageContainer>
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
title="Export Operations"
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
action={
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
<Group gap="xs">
<Button
fw={700}
leftSection={<Truck size={16} />}
onClick={() => setReceiveOpen(true)}
>
Receive for Loading
</Button>
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
</Group>
}
/>
<Card>
<WarehouseFlowWorkbench direction="EXPORT" />
</Card>
<Modal
opened={receiveOpen}
onClose={() => setReceiveOpen(false)}
title="Receive for loading"
centered
size="80rem"
>
<Stack gap="md">
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
<Group justify="flex-end">
<Button variant="default" onClick={() => setReceiveOpen(false)}>
Close
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -15,7 +15,8 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
@@ -31,7 +32,7 @@ import {
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: inv, isLoading } = useQuery(
api.warehouses.invoice.queryOptions({
input: { id: id ?? '' },
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
)}
<Group justify="flex-end" mt="sm">
{inv.bookingId && (
<Button
variant="subtle"
color="gray"
leftSection={<ExternalLink size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
)
}
>
View booking
</Button>
)}
<Button
variant="light"
color="gray"

View File

@@ -656,11 +656,11 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter],
),
eligibleBookings: endpoint<void, EligibleBooking[]>(
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
"warehouse-inventory",
"eligible-bookings",
() => warehouseService.eligibleBookings().then((r) => r.data),
() => ["warehouse-inventory", "eligible-bookings"],
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(

View File

@@ -137,6 +137,10 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob',
}),
downloadGrnDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
responseType: 'blob',
}),
downloadHandoverDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob',

View File

@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
quantity: number;
weight: number;
volume: number | null;
grnNumber: string | null;
status: InventoryStatus;
inspectionStatus: string | null;
arrivedAt: string | null;
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
readyForPickupAt: string | null;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference?: string | null;
handoverDocumentDate?: string | null;
deliveredAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"dev": "vite --port 5173 --clearScreen false",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173",
"lint": "eslint src",

View File

@@ -147,6 +147,16 @@ export const URL_CONSTANTS = {
BILLING: {
MY_INVOICES: "/api/billing/my-invoices",
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
},
WAREHOUSE_INVOICES: {
FOR_BOOKING: (bookingId: string) =>
`/api/bookings/${bookingId}/warehouse-fee-invoices`,
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
},
};

View File

@@ -1,5 +1,4 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
@@ -12,4 +11,3 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/**
* Scroll to the element whose `id` matches the URL hash. Retries for a short
* window so it still lands on sections that mount after an async fetch (the app
* has no router-level hash handling). Deep-link targets give a card an `id`.
*/
export function useScrollToHash(): void {
const { hash } = useLocation();
useEffect(() => {
if (!hash) return;
const id = decodeURIComponent(hash.slice(1));
let tries = 0;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (tries++ < 20) timer = setTimeout(tick, 100);
};
timer = setTimeout(tick, 100);
return () => clearTimeout(timer);
}, [hash]);
}

View File

@@ -9,6 +9,7 @@ import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
interface BookingRowProps {
booking: any;
@@ -35,6 +36,7 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const canApproveDelivery = booking.status === "COMPLETED";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -99,6 +101,12 @@ export const BookingRow = memo(function BookingRow({
<PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (
<ApproveDeliveryButton
bookingId={booking.id}
size="sm"
stopPropagation
/>
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (

View File

@@ -15,9 +15,16 @@ import {
Text,
Title,
} from "@mantine/core";
import { ArrowLeft, CreditCard, Info } from "lucide-react";
import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download";
import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
import {
@@ -49,14 +56,29 @@ export default function InvoiceDetailPage() {
api.invoices.get.queryOptions({ input: { id } }),
);
const payMutation = useMutation(
api.invoices.pay.mutationOptions({
onSuccess: (res) => {
const url = res.clientAction?.url;
if (url) window.location.href = url;
},
}),
);
const [payModalOpen, setPayModalOpen] = useState(false);
// Extracted for payMutation callbacks — guaranteed defined when they run
// (guarded by the early return below).
const invSource = invoice?.source;
const invSourceId = invoice?.sourceId;
const payMutation = useMutation({
mutationFn: async (method: PaymentMethod) => {
const bookingId =
invSource === "warehouse"
? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId!
: invSourceId!;
return api.payments.initiate.call({ bookingId, method });
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: invSourceId!, method });
window.location.href = redirectUrl;
},
});
if (isLoading) {
return (
@@ -90,9 +112,55 @@ export default function InvoiceDetailPage() {
const lines = invoice.lines ?? [];
const handlePay = () => {
const returnUrl = `${window.location.origin}/payment/success`;
const failureUrl = `${window.location.origin}/payment/failure`;
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
setPayModalOpen(true);
};
const hasReceipt = Number(invoice.paidAmount) > 0;
const canViewSource =
invoice.source === "booking" || invoice.source === "warehouse";
const downloadInvoice = async () => {
try {
saveBlob(
await invoicesService.downloadDocument(id),
`invoice-${invoice.invoiceNumber}.pdf`,
);
} catch {
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
}
};
const downloadReceipt = async () => {
try {
saveBlob(
await invoicesService.downloadReceipt(id),
`receipt-${invoice.invoiceNumber}.pdf`,
);
} catch {
toast.error("Receipt isn't available yet.");
}
};
// The source link: a booking invoice goes straight to the booking; a warehouse
// fee invoice resolves its booking (via the warehouse view) and deep-links to
// that booking's warehouse-payments section.
const viewSource = async () => {
if (invoice.source === "booking") {
navigate(`/bookings/${invoice.sourceId}`);
return;
}
if (invoice.source === "warehouse") {
try {
const wh = await warehouseInvoicesService.get(invoice.id);
if (wh?.bookingId) {
navigate(`/bookings/${wh.bookingId}#warehouse-payments`);
return;
}
} catch {
/* fall through to the toast below */
}
toast.error("This invoice's source isn't linked to a booking.");
}
};
return (
@@ -117,27 +185,58 @@ export default function InvoiceDetailPage() {
</Title>
<InvoiceStatusBadge status={invoice.status} />
</Group>
{payable && (
<Group gap={8} wrap="wrap">
{canViewSource && (
<Button
variant="default"
radius="md"
size="md"
leftSection={<ExternalLink size={16} />}
onClick={viewSource}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
>
View source
</Button>
)}
<Button
color="edr-green"
variant="default"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
leftSection={<Download size={16} />}
onClick={downloadInvoice}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
Download invoice
</Button>
)}
{hasReceipt && (
<Button
variant="subtle"
color="gray"
radius="md"
size="md"
leftSection={<Receipt size={16} />}
onClick={downloadReceipt}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
>
Receipt
</Button>
)}
{payable && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button>
)}
</Group>
</Group>
{payMutation.isError && (
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
Please try again, or contact support if the problem persists.
</Alert>
)}
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
@@ -241,6 +340,27 @@ export default function InvoiceDetailPage() {
</Table>
</Box>
</Paper>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)}
currency={invoice.currency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</Stack>
</Box>
);

View File

@@ -22,6 +22,7 @@ const STATUS_STYLE: Record<
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
[Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" },
};
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {

View File

@@ -11,6 +11,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard";
@@ -23,19 +24,22 @@ import {
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { view, viewer } = useFileViewer();
@@ -72,6 +76,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
(isGeneralContract
? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery = status === "COMPLETED";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
@@ -93,14 +98,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<PageHeader
booking={booking}
actions={
canPay &&
!showCountdown && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
(canApproveDelivery || (canPay && !showCountdown)) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
)}
{canPay && !showCountdown && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
@@ -156,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<ShipmentTrackingCard bookingId={booking.id} />
<WarehousePaymentsSection bookingId={booking.id} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
@@ -207,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
right={
<>
{showCountdown && (
<PaymentDeadlineCard
paymentDeadline={booking.paymentDeadline!}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
/>
)}
<PaymentCard booking={booking} pricing={pricing} />
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
showCountdown={showCountdown}
/>
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
@@ -248,4 +260,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{viewer}
</PageShell>
);
}
}

View File

@@ -0,0 +1,370 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
CreditCard,
Download,
FileText,
Receipt,
Timer,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { saveBlob } from "@/utils/download";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
import { CardTitle, SectionCard } from "./layout";
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
// ── Pay-window countdown ─────────────────────────────────────────────────────
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
expired: boolean;
}
function getRemaining(deadlineMs: number): Remaining {
const diff = deadlineMs - Date.now();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
const total = Math.floor(diff / 1000);
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
expired: false,
};
}
function Segment({ value, label }: { value: number; label: string }) {
return (
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
<Text fz="26px" fw={800} c="#10202F" lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
{String(value).padStart(2, "0")}
</Text>
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.6px" }}>
{label}
</Text>
</Stack>
);
}
function Countdown({
deadline,
onPay,
paying,
}: {
deadline: string;
onPay?: () => void;
paying?: boolean;
}) {
const deadlineMs = new Date(deadline).getTime();
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
useEffect(() => {
setRemaining(getRemaining(deadlineMs));
const interval = setInterval(() => {
const next = getRemaining(deadlineMs);
setRemaining(next);
if (next.expired) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
}, [deadlineMs]);
if (remaining.expired) {
return (
<Text fz="13.5px" c="#6B7C8E">
The payment window has closed. Move this booking to another schedule or
contact support.
</Text>
);
}
return (
<>
<Group justify="space-between" wrap="nowrap" px={4}>
<Segment value={remaining.days} label="Days" />
<Segment value={remaining.hours} label="Hrs" />
<Segment value={remaining.minutes} label="Min" />
<Segment value={remaining.seconds} label="Sec" />
</Group>
<Text mt={12} fz="12px" c="#9AA8B5">
Deadline:{" "}
{new Date(deadline).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
{onPay && (
<Button
fullWidth
mt={14}
radius={10}
color="edr-green"
leftSection={<CreditCard size={17} />}
onClick={onPay}
loading={paying}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
>
Pay now
</Button>
)}
</>
);
}
// ── Merged payment panel ─────────────────────────────────────────────────────
/**
* One card covering the whole payment story for a booking: the live pay-window
* countdown (when open), the price breakdown, and the invoice(s) — each with a
* link to its detail page and a download. Replaces the separate deadline +
* breakdown cards.
*/
export function BookingPaymentPanel({
booking,
pricing,
onPay,
paying,
showCountdown,
}: {
booking: Freight.IBooking;
pricing: Pricing;
onPay?: () => void;
paying?: boolean;
showCountdown?: boolean;
}) {
const navigate = useNavigate();
const paid = booking.paymentStatus === "PAID";
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const currency = pricing?.currency ?? booking.paymentCurrency;
const total = isAdjusted
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
: priceTotal(pricing);
const items = priceLineItems(pricing);
const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
// The invoice worth a prominent "Download" — the first issued one, else any.
const primary =
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
const downloadInvoice = async (inv: PortalInvoice) => {
try {
saveBlob(
await invoicesService.downloadDocument(inv.id),
`invoice-${inv.invoiceNumber}.pdf`,
);
} catch {
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
}
};
const downloadReceipt = async (inv: PortalInvoice) => {
try {
saveBlob(
await invoicesService.downloadReceipt(inv.id),
`receipt-${inv.invoiceNumber}.pdf`,
);
} catch {
toast.error("Receipt isn't available yet.");
}
};
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: paid ? "#ECF6F1" : showCountdown ? "#FEF6E6" : "#FDF3E0",
color: paid ? "#0A6F4D" : showCountdown ? "#B07D14" : "#9A5B00",
border: paid ? "1px solid #CDEBDD" : undefined,
}}
>
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
{paid
? "Paid"
: showCountdown
? "Pay window open"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
</Group>
</Group>
{showCountdown && booking.paymentDeadline && (
<Box mt={16}>
<Countdown
deadline={booking.paymentDeadline}
onPay={onPay}
paying={paying}
/>
<Divider />
</Box>
)}
<Box mt={showCountdown ? 0 : 12}>
<Text fz="26px" fw={800} c="#10202F">
{total}
</Text>
{isAdjusted && (
<Box
component="span"
mt={6}
style={{
display: "inline-block",
borderRadius: 6,
backgroundColor: "#EAF1FB",
padding: "3px 8px",
fontSize: 11,
fontWeight: 700,
color: "#2E5B96",
}}
>
Adjusted by EDR
</Box>
)}
{isAdjusted && booking.adjustmentReason && (
<Text mt={6} fz="12.5px" c="#6B7C8E">
{booking.adjustmentReason}
</Text>
)}
{paid && (
<Text mt={4} fz="12.5px" c="#9AA8B5">
Paid · {fmtDate(booking.updatedAt)}
</Text>
)}
</Box>
{items.length > 0 && (
<>
<Divider />
<Stack gap={11}>
{items.map((it) => (
<Group key={it.label} justify="space-between" wrap="nowrap">
<Text fz="13px" c="#6B7C8E">
{it.label}
</Text>
<Text fz="13px" fw={600} c="#10202F">
{it.value}
</Text>
</Group>
))}
</Stack>
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
{isAdjusted ? "Adjusted total" : "Total"}
</Text>
<Text fz="15px" fw={800} c="#10202F">
{total}
</Text>
</Group>
</>
)}
{invoices.length > 0 && (
<>
<Divider />
<Group justify="space-between" align="center" mb={10}>
<CardTitle>Invoices</CardTitle>
<Text fz="12px" c="#9AA8B5">
{invoices.length}
</Text>
</Group>
<Stack gap={10}>
{invoices.map((inv) => (
<Group key={inv.id} justify="space-between" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Text
fz="13px"
fw={700}
c="#10202F"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/billing/${inv.id}`)}
>
{inv.invoiceNumber}
</Text>
<Text fz="12px" c="#9AA8B5">
{titleCase(inv.type)}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<InvoiceStatusBadge status={inv.status} />
<ActionIcon
variant="subtle"
color="gray"
aria-label="Download invoice"
onClick={() => downloadInvoice(inv)}
>
<Download size={16} />
</ActionIcon>
</Group>
</Group>
))}
</Stack>
</>
)}
{primary && (
<Button
fullWidth
mt={16}
variant="default"
radius={10}
leftSection={<FileText size={17} color="#475569" />}
onClick={() => downloadInvoice(primary)}
styles={{
root: { height: 46 },
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
}}
>
Download invoice
</Button>
)}
{primary && primaryPaid && (
<Button
fullWidth
mt={8}
variant="subtle"
color="gray"
radius={10}
leftSection={<Receipt size={17} />}
onClick={() => downloadReceipt(primary)}
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
>
Download receipt
</Button>
)}
</SectionCard>
);
}

View File

@@ -1,151 +0,0 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { CreditCard, Timer } from "lucide-react";
import { useEffect, useState } from "react";
import { CardTitle, SectionCard } from "./layout";
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
expired: boolean;
}
function getRemaining(deadlineMs: number): Remaining {
const diff = deadlineMs - Date.now();
if (diff <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
}
const totalSeconds = Math.floor(diff / 1000);
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
expired: false,
};
}
function Segment({ value, label }: { value: number; label: string }) {
return (
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
<Text
fz="28px"
fw={800}
c="#10202F"
lh={1}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{String(value).padStart(2, "0")}
</Text>
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
{label}
</Text>
</Stack>
);
}
export function PaymentDeadlineCard({
paymentDeadline,
onPay,
paying,
}: {
/** ISO timestamp marking the end of the pay window. */
paymentDeadline: string;
onPay?: () => void;
paying?: boolean;
}) {
const deadlineMs = new Date(paymentDeadline).getTime();
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
useEffect(() => {
setRemaining(getRemaining(deadlineMs));
const interval = setInterval(() => {
const next = getRemaining(deadlineMs);
setRemaining(next);
if (next.expired) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
}, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
<SectionCard
p={22}
style={
remaining.expired
? undefined
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
}
>
<Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: accentBg,
color: accentFg,
}}
>
<Timer size={13} />
{remaining.expired ? "Expired" : "Pay window open"}
</Group>
</Group>
{remaining.expired ? (
<Text mt={14} fz="13.5px" c="#6B7C8E">
The payment window has closed. Move this booking to another schedule or
contact support.
</Text>
) : (
<>
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
<Segment value={remaining.days} label="Days" />
<Segment value={remaining.hours} label="Hrs" />
<Segment value={remaining.minutes} label="Min" />
<Segment value={remaining.seconds} label="Sec" />
</Group>
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
Complete payment before the window closes to secure your slot.
</Text>
{onPay && (
<Button
fullWidth
mt={16}
radius={10}
color="edr-green"
leftSection={<CreditCard size={17} />}
onClick={onPay}
loading={paying}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
>
Pay now
</Button>
)}
</>
)}
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
<Text mt={12} fz="12px" c="#9AA8B5">
Deadline:{" "}
{new Date(paymentDeadline).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</SectionCard>
);
}

View File

@@ -0,0 +1,156 @@
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Download, Receipt } from "lucide-react";
import toast from "react-hot-toast";
import {
warehouseInvoicesService,
type PortalWarehouseInvoice,
} from "@/services/warehouse-invoices.service";
import { saveBlob } from "@/utils/download";
import { CardTitle, SectionCard } from "./layout";
const money = (amount: number | string | null | undefined, currency: string) =>
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
ISSUED: { bg: "#FEF3E2", fg: "#B45309" },
PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" },
PAID: { bg: "#E6F7EF", fg: "#0A6F4D" },
CANCELLED: { bg: "#EEF2F6", fg: "#64748B" },
};
function StatusPill({ status }: { status: string }) {
const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" };
return (
<Box
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 9px",
borderRadius: 999,
background: s.bg,
color: s.fg,
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{status.replace(/_/g, " ")}
</Box>
);
}
/**
* Warehouse fee invoices linked to this booking — display + PDF download only.
* Paying them online is tracked separately (in-system demurrage/storage
* payment). Renders nothing when the booking has no warehouse fees. Carries
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
*/
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const { data: invoices = [] } = useQuery({
queryKey: ["booking-warehouse-invoices", bookingId],
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
});
if (invoices.length === 0) return null;
const download = async (inv: PortalWarehouseInvoice) => {
try {
saveBlob(
await warehouseInvoicesService.downloadDocument(inv.id),
`warehouse-invoice-${inv.invoiceNumber}.pdf`,
);
} catch {
toast.error("Warehouse invoice PDF isn't ready yet.");
}
};
const downloadReceipt = async (inv: PortalWarehouseInvoice) => {
try {
saveBlob(
await warehouseInvoicesService.downloadReceipt(inv.id),
`warehouse-receipt-${inv.invoiceNumber}.pdf`,
);
} catch {
toast.error("Receipt isn't available yet.");
}
};
return (
<SectionCard id="warehouse-payments">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Warehouse payments</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{invoices.length} {invoices.length === 1 ? "invoice" : "invoices"}
</Text>
</Group>
<Stack gap={12}>
{invoices.map((inv) => {
const detail = [
inv.invoiceType?.replace(/_/g, " "),
inv.cargoDescription ??
inv.containerNumber ??
inv.inventoryReference ??
undefined,
]
.filter(Boolean)
.join(" · ");
return (
<Group
key={inv.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
style={{
border: "1px solid #EEF2F6",
borderRadius: 12,
padding: "12px 14px",
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="13.5px" fw={700} c="#10202F">
{inv.invoiceNumber}
</Text>
<StatusPill status={inv.status} />
</Group>
{detail && (
<Text fz="12px" c="#9AA8B5" mt={2}>
{detail}
</Text>
)}
<Text fz="12.5px" c="#6B7C8E" mt={4}>
Total {money(inv.totalAmount, inv.currency)} · Balance{" "}
{money(inv.balanceAmount, inv.currency)}
</Text>
</Box>
<Group gap={6} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
aria-label="Download invoice"
onClick={() => download(inv)}
>
<Download size={16} />
</ActionIcon>
{Number(inv.paidAmount) > 0 && (
<ActionIcon
variant="subtle"
color="gray"
aria-label="Download receipt"
onClick={() => downloadReceipt(inv)}
>
<Receipt size={16} />
</ActionIcon>
)}
</Group>
</Group>
);
})}
</Stack>
</SectionCard>
);
}

View File

@@ -1,9 +1,7 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, Clock } from "lucide-react";
import { Clock } from "lucide-react";
import type { Freight } from "@edr/types";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
import { priceLineItems, priceTotal, type Pricing } from "../utils";
import { CardTitle, SectionCard } from "./layout";
function LineItems({ pricing }: { pricing: Pricing }) {
@@ -107,113 +105,6 @@ export function EstimateCard({
);
}
export function PaymentCard({
booking,
pricing,
}: {
booking: Freight.IBooking;
pricing: Pricing;
}) {
const paid = booking.paymentStatus === "PAID";
// Customer sees the grand total plus the price breakdown that makes it up.
// A staff adjustment, when present, overrides the computed total and is
// flagged with an "Adjusted by EDR" badge.
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const currency = pricing?.currency ?? booking.paymentCurrency;
const total = isAdjusted
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
: priceTotal(pricing);
const hasItems = priceLineItems(pricing).length > 0;
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
color: paid ? "#0A6F4D" : "#9A5B00",
border: paid ? "1px solid #CDEBDD" : undefined,
}}
>
{paid && <CheckCircle2 size={13} />}
{paid
? "Paid"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
</Group>
</Group>
<Box mt={12}>
<Text fz="26px" fw={800} c="#10202F">
{total}
</Text>
{isAdjusted && (
<Box
component="span"
mt={6}
style={{
display: "inline-block",
borderRadius: 6,
backgroundColor: "#EAF1FB",
padding: "3px 8px",
fontSize: 11,
fontWeight: 700,
color: "#2E5B96",
}}
>
Adjusted by EDR
</Box>
)}
{isAdjusted && booking.adjustmentReason && (
<Text mt={6} fz="12.5px" c="#6B7C8E">
{booking.adjustmentReason}
</Text>
)}
{paid && (
<Text mt={4} fz="12.5px" c="#9AA8B5">
Paid · {fmtDate(booking.updatedAt)}
</Text>
)}
</Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
{isAdjusted ? "Adjusted total" : "Total"}
</Text>
<Text fz="15px" fw={800} c="#10202F">
{total}
</Text>
</Group>
</>
)}
{/* <Button */}
{/* fullWidth */}
{/* mt={16} */}
{/* variant="default" */}
{/* radius={10} */}
{/* leftSection={<FileText size={17} color="#475569" />} */}
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
{/* > */}
{/* Download invoice */}
{/* </Button> */}
</SectionCard>
);
}
// The booking payment card (countdown + breakdown + invoices + download) now
// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and
// changes-requested views, which only show an estimate.

View File

@@ -0,0 +1,73 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
stopPropagation?: boolean;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
onApproved,
size = "sm",
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async () => {
toast.success("Delivery approved and handover signed");
await Promise.all([
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
}
},
});
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
);
}

View File

@@ -8,6 +8,7 @@ import type {
} from "@/types/fileUploadSettings";
import {
bookingsService,
type ApproveDeliveryResponse,
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
@@ -303,6 +304,12 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId),
),
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
"bookings",
"approveDelivery",
({ id }) => bookingsService.approveDelivery(id),
),
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]

View File

@@ -29,12 +29,39 @@ export const invoicesService = {
return data.data ?? data;
},
/** The customer's invoices for one source record (e.g. a booking). */
listForSource: async (
source: string,
sourceId: string,
): Promise<PortalInvoice[]> => {
const { data } = await client.get(B.MY_INVOICES, {
params: { source, sourceId },
});
return data.data ?? data;
},
/** One of the customer's invoices, with its line items. */
get: async (id: string): Promise<PortalInvoiceDetail> => {
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
return data.data ?? data;
},
/** The sealed invoice PDF for one of the customer's invoices. */
downloadDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
/** The sealed payment-receipt PDF (available once paid). */
downloadReceipt: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), {
responseType: "blob",
});
return data;
},
/** Initiate gateway payment for an open invoice; returns the client action. */
pay: async (
id: string,

View File

@@ -0,0 +1,54 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
/**
* A warehouse fee invoice as the freight API projects it for the customer
* (the historical `WarehouseFeeInvoice` view shape — a subset is used here).
*/
export interface PortalWarehouseInvoice {
id: string;
invoiceNumber: string;
invoiceType: string;
status: string;
currency: string;
totalAmount: number | string;
paidAmount: number | string;
balanceAmount: number | string;
issuedAt?: string | null;
dueDate?: string | null;
paidAt?: string | null;
bookingId?: string | null;
inventoryId?: string | null;
bookingReference?: string | null;
inventoryReference?: string | null;
cargoDescription?: string | null;
containerNumber?: string | null;
}
export const warehouseInvoicesService = {
/** Warehouse fee invoices linked to a booking (via its inventory items). */
listForBooking: async (bookingId: string): Promise<PortalWarehouseInvoice[]> => {
const { data } = await client.get(W.FOR_BOOKING(bookingId));
return data.data ?? data;
},
/** A single warehouse fee invoice (carries `bookingId` for source linking). */
get: async (id: string): Promise<PortalWarehouseInvoice> => {
const { data } = await client.get(W.BY_ID(id));
return data.data ?? data;
},
/** The sealed warehouse fee invoice PDF. */
downloadDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" });
return data;
},
/** The sealed warehouse fee payment receipt PDF (available once paid). */
downloadReceipt: async (id: string): Promise<Blob> => {
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
return data;
},
};

View File

@@ -0,0 +1,11 @@
/** Trigger a browser download of a Blob under `filename`. */
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View File

@@ -3,6 +3,7 @@ NODE_ENV=development
PORT=4000
# Database (Prisma) — owns the `passenger` schema in edr_database
# Production: append ?sslmode=require&connection_limit=10&pool_timeout=20 to enforce SSL and connection pooling
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
@@ -32,14 +33,14 @@ FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184
# JWT (legacy passenger auth — being replaced by IAM)
JWT_SECRET=edr-platform-secret-change-in-production
# REQUIRED in production — use a random 32+ character string (e.g. openssl rand -hex 32)
JWT_SECRET=<change-me-min-32-chars>
JWT_EXPIRES_IN=7d
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
# @tria-plc IAM token contract — REQUIRED in production. MUST match the IAM issuer's secret.
JWT_ACCESS_TOKEN_SECRET=<change-me-min-32-chars>
JWT_ACCESS_TOKEN_EXPIRES=1h
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
JWT_REFRESH_TOKEN_SECRET=<change-me-min-32-chars>
JWT_REFRESH_TOKEN_EXPIRES=7d
# SendGrid
@@ -157,7 +158,7 @@ FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).

View File

@@ -4,6 +4,12 @@
# `migration` stage, invoked as a one-shot container in CI before deploy.
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Put the pnpm content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` below actually persists it across
# builds. Without this, pnpm stores in ~/.local/share/pnpm/store and the
# cache mount is a no-op — deps re-download on every pipeline run.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS pruner
@@ -22,11 +28,14 @@ RUN pnpm --filter "@edr/passenger-api" exec prisma generate
RUN pnpm turbo build --filter="@edr/passenger-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
# Copy prisma directory and generate client in deploy location
RUN cp -r apps/edr-passenger-api/prisma /deploy/ && \
cd /deploy && \
npx prisma generate --schema=prisma/schema.prisma
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
# The generated Prisma client is NOT in the pnpm store (it's an output of
# `prisma generate`), so `pnpm deploy` does not copy it into /deploy. Regenerate
# it here so the runtime enum values imported from @prisma/client (Currency, …)
# are real objects instead of undefined — otherwise @IsEnum(Currency) throws
# "Cannot convert undefined or null to object" at module load.
RUN cd /deploy && npm run prisma:generate
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
# against the real DB, as its own gated step *before* the app image is built/deployed.
@@ -37,10 +46,7 @@ WORKDIR /deploy
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Copy the resolution script
COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/
RUN chmod +x /deploy/scripts/resolve-migrations.sh
CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat

View File

@@ -1,10 +0,0 @@
-- This migration fixes the failed state of 20240101000000_individual_tickets_no_timezone
-- It marks the failed migration as rolled back so it can be retried
-- Mark the failed migration as rolled back
UPDATE passenger._prisma_migrations
SET rolled_back_at = CURRENT_TIMESTAMP,
logs = 'Migration failed due to missing TicketSeat table. Automatically rolled back by fix migration to allow retry with idempotent SQL.'
WHERE migration_name = '20240101000000_individual_tickets_no_timezone'
AND rolled_back_at IS NULL
AND finished_at IS NULL;

View File

@@ -1,77 +0,0 @@
-- DropForeignKey (only if table exists)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'passenger'
AND table_name = 'TicketSeat'
) THEN
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
END IF;
END $$;
-- DropIndex
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
-- AlterTable: Station
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
-- AlterTable: Ticket — add columns with safe defaults
ALTER TABLE "passenger"."Ticket"
ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "scheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT '';
-- DropTable
DROP TABLE IF EXISTS "passenger"."TicketSeat";
-- Remove GateValidationLog rows referencing orphan tickets first (only if tickets have seatId column)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'passenger'
AND table_name = 'Ticket'
AND column_name = 'seatId'
) THEN
DELETE FROM "passenger"."GateValidationLog"
WHERE "ticketId" IN (
SELECT "id" FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
);
-- Remove orphan ticket rows
DELETE FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
END IF;
END $$;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
-- AddForeignKey (only if not already exists)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'passenger'
AND table_name = 'Ticket'
AND column_name = 'seatId'
) AND NOT EXISTS (
SELECT FROM information_schema.table_constraints
WHERE constraint_schema = 'passenger'
AND constraint_name = 'Ticket_seatId_fkey'
) THEN
ALTER TABLE "passenger"."Ticket"
ADD CONSTRAINT "Ticket_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -1,3 +0,0 @@
-- Drop temporary defaults that were only needed for the backfill
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT;

View File

@@ -1,2 +0,0 @@
-- Remove timezone column if it still exists
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT;

View File

@@ -1,127 +0,0 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
CREATE TABLE IF NOT EXISTS "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"effective_date" TIMESTAMP(3) NOT NULL,
"expiry_date" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT false,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_by" TEXT,
"approved_by" TEXT,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id")
);
-- Rate structure by nationality and coach/position
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
"coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED'
"bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats
"rate_per_km_minor" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id")
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE IF NOT EXISTS "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
"component_name" TEXT NOT NULL,
"calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT'
"value_minor" INTEGER, -- For fixed amounts
"percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%)
"applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL'
"apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id")
);
-- Age-based pricing rules
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
"min_age" INTEGER NOT NULL,
"max_age" INTEGER,
"pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED'
"discount_percentage" DECIMAL(5,4), -- For discounted fares
"max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child)
"applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id")
);
-- Audit trail for configuration changes
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
"changed_by" TEXT,
"changes" JSONB, -- Store the actual changes made
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
);
-- Foreign key constraints (idempotent)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Indexes for performance (idempotent)
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
-- Add feature flag support
CREATE TABLE IF NOT EXISTS "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
);
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -1,14 +0,0 @@
-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced)
ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT;
-- Unique constraint: one IAM user maps to exactly one Passenger
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
-- Index for fast lookup by iamUserId on every protected request
CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId");
-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users)
ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT;
-- Index for Fayda callback to resolve IAM user
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId");

View File

@@ -1,28 +0,0 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,54 +0,0 @@
-- DropForeignKey
ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- AlterTable
ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId");
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey"
FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -1,13 +0,0 @@
-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema)
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
-- Rename columns (preserves all existing data)
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
-- Rename indexes on FraudAlert to match new column name
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");

View File

@@ -1,10 +0,0 @@
-- AuditLog: drop FK, rename column, update index
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data)
ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey";
ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId";
DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx";

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
-- RenameIndex
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";

View File

@@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';

View File

@@ -1,275 +0,0 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
ADD COLUMN "returnHoldId" TEXT,
ADD COLUMN "returnOriginStationId" TEXT,
ADD COLUMN "returnScheduleId" TEXT,
ADD COLUMN "returnSeatClassId" TEXT;
-- AlterTable
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- gender column already TEXT from init migration
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- Empty placeholder migration
SELECT 1;

View File

@@ -1,9 +0,0 @@
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
-- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");

View File

@@ -1,164 +0,0 @@
-- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- Coach relation
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
-- Booking relations
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- PaymentIntent
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- PaymentRefund
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- TicketSeat
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
-- Notification
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- MenuItem
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE;
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- AgentShift
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- AgentCommission
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- BookingModification
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- BookingCancellation
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- GateValidationLog
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- RouteFareRule
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- SeatBlock
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- SavedRoute
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;

View File

@@ -1,82 +0,0 @@
-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.*
-- but ran when tables were still in public schema (before 20260626 moved them).
-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run.
-- ────────────────────────────────────────────────────────────
-- 1. Passenger.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Passenger_iamUserId_key'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 2. FaydaVerificationSession.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 4. Device: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
END IF;
END $$;

View File

@@ -1,5 +0,0 @@
-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat).
-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL.
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL;

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