mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/report
This commit is contained in:
@@ -40,6 +40,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
|
||||
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
@@ -201,6 +203,8 @@ if (!process.env.APPLICATION_NAME) {
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CompaniesModule,
|
||||
ShippingLineCompaniesModule,
|
||||
ShippingLineBookingCompletionModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Shipping lines — carriers registered by backoffice staff who sign in to the
|
||||
* portal directly.
|
||||
*
|
||||
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
|
||||
* business licence, eTrade record, operational profile or onboarding state, so
|
||||
* it shares none of the customer columns. `user_id` sits on the company row
|
||||
* itself because the company IS the account — there is no contact-person row.
|
||||
*
|
||||
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
|
||||
* this API reads but never owns.
|
||||
*/
|
||||
export class ShippingLineCompany3440000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_companies_status_enum
|
||||
AS ENUM ('active', 'suspended');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
name varchar(200) NOT NULL,
|
||||
scac_code varchar(4),
|
||||
imo_number varchar(20),
|
||||
bic_code varchar(20),
|
||||
email varchar(150) NOT NULL,
|
||||
phone_number varchar(30),
|
||||
status freight.shipping_line_companies_status_enum
|
||||
NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// One login per shipping line. Partial so a soft-deleted row frees its
|
||||
// account for re-registration rather than blocking it forever.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
|
||||
ON freight.shipping_line_companies (user_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// SCAC identifies the carrier globally — two live lines cannot share one.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
|
||||
ON freight.shipping_line_companies (scac_code)
|
||||
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
|
||||
ON freight.shipping_line_companies (lower(email))
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
|
||||
ON freight.shipping_line_companies (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines book rail capacity directly, without a contract.
|
||||
*
|
||||
* A booking has always been owned by `company_id` (a customer `companies` row),
|
||||
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
|
||||
* company — it carries no TIN, licence or operational profiles. So it gets its
|
||||
* own nullable owner column rather than a synthetic company row.
|
||||
*
|
||||
* Exactly one of the two is set: `company_id` for a customer booking,
|
||||
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
|
||||
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
|
||||
* backfilling and every customer query filtering on `company_id` behaves
|
||||
* exactly as before. Government bookings already bill to a seeded government
|
||||
* company, so they satisfy the CHECK unchanged.
|
||||
*
|
||||
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
|
||||
* is cargo metadata naming the carrier line that moves the goods
|
||||
* (`freight.shipping_lines`, reference data). This column points at
|
||||
* `freight.shipping_line_companies` — the portal account — and is unrelated.
|
||||
*/
|
||||
export class BookingShippingLine3450000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
|
||||
ON freight.bookings (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
|
||||
// tables, so a shipping-line booking could not be inserted at all. Relax
|
||||
// them to nullable; their foreign keys are left in place and keep validating
|
||||
// every non-NULL value, so a customer booking is constrained exactly as
|
||||
// before. The CHECK below is what now guarantees an owner is present.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Route and service are inherited from the contract on a customer booking.
|
||||
// A shipping line initiates before any of that is known — the bare booking
|
||||
// exists only to hang documents off — so these are relaxed too and filled
|
||||
// in when the booking is completed. Existing rows all have values, and the
|
||||
// customer paths still always set them.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
|
||||
`);
|
||||
|
||||
// No FK: kept consistent with how the column is populated at the service
|
||||
// layer, and avoids a lock on shipping_line_companies during deploy.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_single_owner
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.bookings
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
for (const column of [
|
||||
"company_id",
|
||||
"company_profile_id",
|
||||
"origin_yard_id",
|
||||
"destination_yard_id",
|
||||
"service_type_id",
|
||||
"freight_type",
|
||||
]) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines consume services before paying for them.
|
||||
*
|
||||
* A shipping line books rail capacity and the booking proceeds with no payment
|
||||
* gate at all — unlike a customer booking, which cannot advance until its
|
||||
* PREPAID invoice settles. What the line owes is instead recorded here as a
|
||||
* credit: one row per booking, priced once and never recalculated. Finance
|
||||
* later selects a batch of unbilled credits, generates a single invoice for
|
||||
* them, and the line pays that invoice through the normal CBE flow. When the
|
||||
* invoice settles, its credits are marked paid and stop counting as debt.
|
||||
*
|
||||
* This is deliberately NOT a wallet or a stored balance. There is no money in
|
||||
* the system to draw down: a credit is a debt the line already incurred, so
|
||||
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
|
||||
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
|
||||
*
|
||||
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
|
||||
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
|
||||
* shipping line is not a `companies` row and never will be, so an invoice
|
||||
* billed to one has no customer to point at. Both FKs stay in place and keep
|
||||
* validating every non-NULL value, so a customer invoice is constrained
|
||||
* exactly as before; the CHECK below is what now guarantees a payer exists.
|
||||
*/
|
||||
export class ShippingLineCredits3460000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Invoices: allow a shipping-line payer ────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
|
||||
ON freight.invoices (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
|
||||
// answer "who owes this?" the same way. Existing rows all have company_id
|
||||
// and a NULL shipping_line_company_id, so nothing needs backfilling.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD CONSTRAINT chk_invoices_single_payer
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── The credit ledger ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
|
||||
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
shipping_line_company_id uuid NOT NULL,
|
||||
booking_id uuid NOT NULL,
|
||||
amount numeric(14,2) NOT NULL,
|
||||
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
|
||||
status freight.shipping_line_credits_status_enum
|
||||
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
|
||||
description character varying(255),
|
||||
invoice_id uuid,
|
||||
billed_at timestamp with time zone,
|
||||
paid_at timestamp with time zone,
|
||||
cancelled_at timestamp with time zone,
|
||||
cancellation_reason character varying(255),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
|
||||
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
|
||||
-- The state machine, enforced in the DB rather than trusted to the
|
||||
-- service: an UNBILLED credit has no invoice, and anything past
|
||||
-- UNBILLED must name the invoice it was billed on. Without this a
|
||||
-- half-applied batch could leave BILLED rows with a NULL invoice_id
|
||||
-- and silently vanish from both the unbilled list and the invoice.
|
||||
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
|
||||
(status = 'UNBILLED' AND invoice_id IS NULL)
|
||||
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
|
||||
OR status = 'CANCELLED'
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_booking
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// SET NULL rather than CASCADE: deleting an invoice must never delete the
|
||||
// record of what was owed. The row would then violate the link CHECK, so a
|
||||
// credit whose invoice is removed has to be walked back to UNBILLED
|
||||
// explicitly — which is the correct, visible outcome.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_invoice
|
||||
FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices(id) ON DELETE SET NULL
|
||||
`);
|
||||
|
||||
// One live credit per booking. Partial so a soft-deleted or cancelled row
|
||||
// does not block re-pricing a booking that was voided and rebooked.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
|
||||
ON freight.shipping_line_credits (booking_id)
|
||||
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
|
||||
`);
|
||||
|
||||
// Drives the two hot reads: finance's unbilled worklist per line, and the
|
||||
// outstanding total on the shipping-line detail page.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
|
||||
ON freight.shipping_line_credits (shipping_line_company_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
|
||||
ON freight.shipping_line_credits (invoice_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.shipping_line_credits
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.invoices
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-shipping-line rates.
|
||||
*
|
||||
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
|
||||
* and negotiates its own prices, so the rate table gains an owner column:
|
||||
* `shipping_line_company_id` NULL = the standard rate every customer pays,
|
||||
* NOT NULL = a rate that only that line's bookings resolve.
|
||||
*
|
||||
* Points at `freight.shipping_line_companies` (the portal account that owns the
|
||||
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
|
||||
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
|
||||
* already keys off it. Both stay independent.
|
||||
*
|
||||
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
|
||||
* that line's rate for the lane, and is hard-blocked when none exists (the
|
||||
* standard rate is deliberately not a fallback — see RuleEngineService).
|
||||
*
|
||||
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
|
||||
* standard-rate lookups behave exactly as before.
|
||||
*/
|
||||
export class ShippingLineRates3470000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_shipping_line_company"
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// Rate resolution always filters by owner, so the lookups this column
|
||||
// participates in are (owner, lane) — indexed together with rate_type,
|
||||
// which every lookup also pins.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
|
||||
ON freight.rates (shipping_line_company_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
|
||||
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
|
||||
// A shipping line sells import freight only — the export leg is contracted
|
||||
// through the customer, not the carrier. Enforced here so a line rate can
|
||||
// never be filed against an export lane regardless of which API path wrote
|
||||
// it. Surcharges carry no direction and are unaffected.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
shipping_line_company_id IS NULL OR
|
||||
trade_direction IS NULL OR trade_direction = 'IMPORT'
|
||||
)
|
||||
`);
|
||||
|
||||
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
|
||||
// rate collides with the standard rate for the same lane — same rate_type,
|
||||
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
|
||||
// (the standard rate) collapses to the zero uuid like every other nullable
|
||||
// scope column, so existing rows keep their current uniqueness exactly.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restore the pre-owner pattern index (as left by LastMileRateBands).
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A train schedule can be dedicated to one shipping line.
|
||||
*
|
||||
* NULL = a normal train, visible and bookable to customers as before. Set =
|
||||
* the departure exists for that shipping line alone: it is excluded from every
|
||||
* customer-facing read (booking windows, day pools, portal home cards) and
|
||||
* surfaces only in the assigned line's portal (home page + booking detail).
|
||||
*/
|
||||
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
|
||||
ON freight.train_schedules (shipping_line_company_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Default the daily booking desk to 24 hours: window_close_hour equal to
|
||||
* window_open_hour means the desk never pauses overnight. Aligns the column
|
||||
* default and the existing global-rules row; per-schedule overrides keep
|
||||
* whatever staff set on them.
|
||||
*/
|
||||
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 8
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = window_open_hour
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 17
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = 17
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
|
||||
* manual paths exist for finance: recording an offline payment (MARK_PAID)
|
||||
* and voiding an invoice raised in error (CANCEL, which releases its credits
|
||||
* back to the unbilled pool). Both erase or move real debt, so neither is a
|
||||
* single-person action: one permission raises the request, a different
|
||||
* permission — held by a chief, and never the requester themselves — approves
|
||||
* or rejects it. Rows are never deleted; decided requests are the audit trail.
|
||||
*
|
||||
* One PENDING row per invoice at a time (partial unique index): a second
|
||||
* request while one is undecided is a coordination failure, not a workflow.
|
||||
*/
|
||||
export class ShippingLineInvoiceApprovals3530000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
|
||||
AS ENUM ('MARK_PAID', 'CANCEL');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
|
||||
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
|
||||
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
requested_by uuid NOT NULL,
|
||||
reason varchar(500) NOT NULL,
|
||||
payment_reference varchar(255),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id, status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per invoice.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
||||
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
||||
|
||||
// Shipping Line
|
||||
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
|
||||
// not an account)
|
||||
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||
|
||||
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||
|
||||
// Signature
|
||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
@@ -86,7 +87,66 @@ export class CustomerResetService {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
return this.sendResetLinkToUser(resolved.userId, channel, {
|
||||
scope: `company ${companyId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and deliver a reset link to a specific IAM account.
|
||||
*
|
||||
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
||||
* that resolve their target differently can reuse it: a customer is found via
|
||||
* the company's primary contact, while a shipping line has no contact row at
|
||||
* all and resolves straight off its own record. Everything below the lookup —
|
||||
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
||||
* undelivered-link diagnostic — is identical for both and must stay that way.
|
||||
*
|
||||
* `scope` only labels the log line with whatever the caller resolved from.
|
||||
*
|
||||
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
||||
* the default gate requires an existing active credential (so a reset cannot
|
||||
* revive a suspended account), but an account that has never set a password
|
||||
* has no credential row yet and would be excluded from its own activation
|
||||
* link. Callers pass it only when the account is expected to be
|
||||
* password-less — see ShippingLineCompaniesService.
|
||||
*/
|
||||
async sendResetLinkToUser(
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): Promise<SentResetLink | null> {
|
||||
const user = options?.allowWithoutCredential
|
||||
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`User ${userId} is not an active account${
|
||||
options?.allowWithoutCredential
|
||||
? ""
|
||||
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.deliverResetLink(user, user.id, channel, options?.scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail: target selection → SMS reachability → mint → send → report.
|
||||
* Callers have already resolved `user` to an active account.
|
||||
*/
|
||||
private async deliverResetLink(
|
||||
user: User,
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
scope?: string,
|
||||
): Promise<SentResetLink | null> {
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${"link"}`,
|
||||
);
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
@@ -110,6 +170,9 @@ export class CustomerResetService {
|
||||
);
|
||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${link}`,
|
||||
);
|
||||
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
@@ -127,7 +190,22 @@ export class CustomerResetService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} queued=${queued}`,
|
||||
);
|
||||
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
||||
// read access to the log stream can set the password for the account named
|
||||
// on the same line — including on sends that succeeded, not just failures.
|
||||
// Kept deliberately: log aggregation is the debugging path for flaky
|
||||
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
||||
// that is ever revisited, gate this on an env flag rather than deleting it,
|
||||
// so dev keeps its workflow.
|
||||
this.logger.warn(
|
||||
`reset-link.cleartext channel=${channel} user=${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} link=${link}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
|
||||
@@ -89,6 +89,28 @@ export class ForgotPasswordService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active account by id, WITHOUT requiring an existing credential.
|
||||
*
|
||||
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
|
||||
* is right for a *reset*: it stops a staff-triggered link from reactivating a
|
||||
* suspended account. But an account that has never set a password has no
|
||||
* credential row yet, so that join excludes exactly the accounts a first-time
|
||||
* *activation* link is for — shipping lines are created deliberately without
|
||||
* one (see ShippingLineCompaniesService.register).
|
||||
*
|
||||
* The `isActive` gate is kept; only the credential requirement is dropped.
|
||||
*/
|
||||
async resolveActivatableUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.where("u.isActive = true")
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.orderBy("u.createdAt", "DESC")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
|
||||
"This password-reset link is invalid or has expired. Request a new one.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
// Credential-less on purpose: this resolves links for *setting* a password,
|
||||
// which includes first-time activation of an account that has never had one
|
||||
// (shipping lines are created without a credential row). Requiring one here
|
||||
// rejected a perfectly valid activation link before its token was ever
|
||||
// checked. The ticket checks below are what actually authorise the reset.
|
||||
const user = await this.resolveActivatableUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !identifier) throw invalid;
|
||||
if (!user || !identifier) {
|
||||
// Logged because the early return above bypasses the rejection warning
|
||||
// below — without this, an account that fails the lookup produces no
|
||||
// diagnostic at all and looks identical to a bad token.
|
||||
this.logger.warn(
|
||||
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
|
||||
);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
const verification = await this.dataSource
|
||||
.getRepository(UserVerification)
|
||||
|
||||
@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
exports: [CustomerResetService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
// Cycles back via ShippingLineCompaniesModule, which imports this module.
|
||||
forwardRef(() => CompaniesModule),
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
@@ -114,8 +117,16 @@ export interface GenerateInvoiceInput {
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** The customer billed. Omit only when billing a shipping line instead. */
|
||||
companyId?: string | null;
|
||||
companyProfileId?: string | null;
|
||||
/**
|
||||
* The shipping line billed, for an invoice covering batched shipping-line
|
||||
* credits. Mutually exclusive with `companyId` — the DB enforces this via
|
||||
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
|
||||
* setting both or neither before it ever reaches the constraint.
|
||||
*/
|
||||
shippingLineCompanyId?: string | null;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
@@ -141,8 +152,11 @@ export interface InvoiceEventPayload {
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** Null when the payer is a shipping line rather than a customer company. */
|
||||
companyId: string | null;
|
||||
companyProfileId: string | null;
|
||||
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
@@ -563,23 +577,61 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
/**
|
||||
* Resolve a shipping-line company from the signed-in user (null for ordinary
|
||||
* customers). Queried straight off the entity rather than through
|
||||
* ShippingLineCompaniesService — that module already imports billing, so a
|
||||
* service edge back would deepen the forwardRef cycle for one lookup.
|
||||
*/
|
||||
private async resolveShippingLineCompanyId(
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
const line = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { userId } });
|
||||
return line?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices for the signed-in portal user; empty when they have no company.
|
||||
* A payer is either a customer company or a shipping line (enforced by the
|
||||
* DB's single-payer check), so the two lookups cannot both match.
|
||||
*/
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
if (companyId) return this.findByCompany(companyId, filter);
|
||||
|
||||
const shippingLineCompanyId =
|
||||
await this.resolveShippingLineCompanyId(userId);
|
||||
if (!shippingLineCompanyId) return [];
|
||||
return this.invoices.findAll({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
const ownedByCompany =
|
||||
invoice.companyId != null &&
|
||||
invoice.companyId === (await this.resolveCompanyId(userId));
|
||||
const ownedByShippingLine =
|
||||
!ownedByCompany &&
|
||||
invoice.shippingLineCompanyId != null &&
|
||||
invoice.shippingLineCompanyId ===
|
||||
(await this.resolveShippingLineCompanyId(userId));
|
||||
if (!ownedByCompany && !ownedByShippingLine) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
@@ -669,7 +721,6 @@ export class BillingService {
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
console.log("oooooooooo", input);
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
@@ -682,6 +733,21 @@ export class BillingService {
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
// Exactly one payer, checked here so a bad payload fails with a clear
|
||||
// message instead of a raw `chk_invoices_single_payer` violation.
|
||||
const billsCompany = Boolean(input.companyId);
|
||||
const billsShippingLine = Boolean(input.shippingLineCompanyId);
|
||||
if (billsCompany === billsShippingLine) {
|
||||
throw new BadRequestException(
|
||||
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
|
||||
);
|
||||
}
|
||||
if (billsCompany && !input.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
"companyProfileId is required when billing a company.",
|
||||
);
|
||||
}
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
@@ -717,8 +783,9 @@ export class BillingService {
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
companyId: input.companyId ?? null,
|
||||
companyProfileId: input.companyProfileId ?? null,
|
||||
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
@@ -1048,6 +1115,7 @@ export class BillingService {
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
|
||||
@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
|
||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
/** The customer (company) this invoice is billed to. */
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
/**
|
||||
* The customer (company) this invoice is billed to. Null on a shipping-line
|
||||
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
|
||||
* line is deliberately not a `companies` row. A DB CHECK
|
||||
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
|
||||
*/
|
||||
@Column({ name: "company_id", type: "uuid", nullable: true })
|
||||
companyId!: string | null;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||
@Column({ name: "company_profile_id", type: "uuid" })
|
||||
companyProfileId!: string;
|
||||
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
|
||||
companyProfileId!: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile)
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
/**
|
||||
* The shipping line billed, when this invoice bills batched shipping-line
|
||||
* credits rather than a customer booking. Mutually exclusive with
|
||||
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
|
||||
* that edge, and importing the shipping-lines module here would close an
|
||||
* import cycle (shipping-lines already depends on billing).
|
||||
*/
|
||||
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
|
||||
// Both channels come from the same resolver: the company row's own columns
|
||||
// are only half the story (see companyNotifyEmailExpr), and reading them off
|
||||
// the loaded entity silently dropped every mail to a company whose address
|
||||
// lives in `attributes`.
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
// lives in `attributes`. A shipping-line booking has NO company — its
|
||||
// contact lives on the shipping_line_companies row itself.
|
||||
const { phone, email } = b.shippingLineCompanyId
|
||||
? await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId,
|
||||
)
|
||||
: b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking's portal owner: every portal
|
||||
* user of the company, or — for a shipping-line booking — the line's own
|
||||
* account, deep-linked into the shipping-line app rather than the customer
|
||||
* one (its routes live under /shipping-line/*).
|
||||
*/
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (b.shippingLineCompanyId) {
|
||||
void (async () => {
|
||||
const { userId } = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId!,
|
||||
);
|
||||
if (!userId) return;
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
// After the spread: overrides carry customer links — the bell must
|
||||
// land a shipping line on ITS booking page.
|
||||
link: `/shipping-line/bookings/${b.id}`,
|
||||
});
|
||||
})().catch((err) =>
|
||||
this.logger.warn(
|
||||
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
|
||||
|
||||
/** Document approval finalized → customer can proceed to request operation. */
|
||||
clearanceReady(b: Booking): void {
|
||||
const msg =
|
||||
`Document approval for booking ${b.reference} is finalized. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
// A shipping line's next move is BOOKING (cargo + shipment day), not the
|
||||
// customer's operation-request step — say so, or the message points at a
|
||||
// flow their portal does not have.
|
||||
const msg = b.shippingLineCompanyId
|
||||
? `Documents for booking ${b.reference} are approved. ` +
|
||||
`You can now book your shipment — enter the cargo and shipment day from the portal.`
|
||||
: `Document approval for booking ${b.reference} is finalized. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
|
||||
this.inApp(b, 'Document approval finalized', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
});
|
||||
this.inApp(
|
||||
b,
|
||||
b.shippingLineCompanyId
|
||||
? 'Documents approved — book your shipment'
|
||||
: 'Document approval finalized',
|
||||
msg,
|
||||
{ type: NotificationType.CLEARANCE_DECISION },
|
||||
);
|
||||
}
|
||||
|
||||
/** Intercity documents approved → booking waits in the ride-along pool. */
|
||||
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
|
||||
|
||||
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||
operationAccepted(b: Booking): void {
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} has been accepted. ` +
|
||||
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||
// No invoice and no pay window for a shipping line — the charge sits on
|
||||
// its credit account and the booking boards its dedicated train directly.
|
||||
const msg = b.shippingLineCompanyId
|
||||
? `Your booking ${b.reference} has been accepted. The charge has been ` +
|
||||
`recorded on your credit account and your shipment is being placed on its train.`
|
||||
: `Your operation request for booking ${b.reference} has been accepted. ` +
|
||||
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
|
||||
this.inApp(b, 'Operation request accepted', msg);
|
||||
this.inApp(
|
||||
b,
|
||||
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,7 +165,7 @@ export class BookingPricingService {
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||||
|
||||
@@ -414,6 +414,9 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
// A shipping line's own booking prices off that line's negotiated rates
|
||||
// instead of the standard customer ones (see RuleEngineService.ratesForOwner).
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
@@ -428,6 +431,22 @@ export class BookingPricingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE rates this booking may price off.
|
||||
*
|
||||
* A shipping-line booking sees only its own line's rates; a customer booking
|
||||
* only the standard ones. Line rates override rather than stack, and the
|
||||
* standard rate is not a fallback — a lane the line has no rate for falls
|
||||
* through to the existing "no rate configured" hard block, which is the
|
||||
* intended outcome rather than silently billing the customer price.
|
||||
*/
|
||||
private async liveRatesForBooking(booking: Booking): Promise<Rate[]> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
return booking.shippingLineCompanyId
|
||||
? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId)
|
||||
: rates.filter((r) => !r.shippingLineCompanyId);
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
@@ -512,7 +531,7 @@ export class BookingPricingService {
|
||||
warnings: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
@@ -713,7 +732,7 @@ export class BookingPricingService {
|
||||
return { lineItems: [], usedRates: [] };
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
|
||||
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
@@ -944,6 +946,15 @@ export class BookingTransitionService {
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
requestedTrainScheduleId?: string | null,
|
||||
opts?: {
|
||||
/**
|
||||
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
|
||||
* the shipping-line completion path, which has already validated the day
|
||||
* against the line's own dedicated train (those trains are excluded from
|
||||
* the customer pools, so the gate here would wrongly reject them).
|
||||
*/
|
||||
bypassDayPool?: boolean;
|
||||
},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -971,20 +982,22 @@ export class BookingTransitionService {
|
||||
// gate; quantity never blocks — oversized bookings get a partial split
|
||||
// offer). The batch engine assigns the specific train within that
|
||||
// (route, day) pool later.
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
if (!opts?.bypassDayPool) {
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export is FCFS and never splits — a booking must ride one train whole. So
|
||||
@@ -1001,7 +1014,14 @@ export class BookingTransitionService {
|
||||
// The customer's train pick only exists for export rail; it rides the
|
||||
// booking through the space checks below AND is persisted so the accept /
|
||||
// reserve path locks onto that train (pickExportSchedule honors it).
|
||||
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
|
||||
// Shipping-line completions (bypassDayPool) pick among the line's own
|
||||
// dedicated trains — already validated by the caller, so the pick is
|
||||
// persisted here the same way an export pick is. Customer import/domestic
|
||||
// bookings still never carry one (the batch engine assigns their train).
|
||||
const requestedId =
|
||||
isExportTrain || opts?.bypassDayPool
|
||||
? (requestedTrainScheduleId ?? null)
|
||||
: null;
|
||||
// Export rail rides the exact train the customer picked — never an
|
||||
// auto-assigned one. Both portal flows (clearance + contract completion)
|
||||
// surface a picker, so a missing id is an invalid submission, not a
|
||||
@@ -1205,10 +1225,22 @@ export class BookingTransitionService {
|
||||
// booking page correctly still showed it as not payable. The batch engine
|
||||
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
||||
// and the real deadline are created — matching the portal's `canPay` gate.
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
//
|
||||
// Shipping-line bookings mint NO invoice at all: they have no company row
|
||||
// to bill (the invoices FK requires one) and they pay on the credit ledger
|
||||
// — the charge was recorded at completion, and Finance bills a batch of
|
||||
// credits later through ShippingLineCreditsService.generateInvoice.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
this.logger.log(
|
||||
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
|
||||
);
|
||||
} else {
|
||||
const invoice =
|
||||
await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
}
|
||||
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
||||
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
||||
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
||||
@@ -1222,6 +1254,7 @@ export class BookingTransitionService {
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
const roadFresh = await this.bookingsService.findById(booking.id);
|
||||
this.emitShippingLineAccepted(roadFresh);
|
||||
this.notifier.operationAccepted(roadFresh);
|
||||
return roadFresh;
|
||||
}
|
||||
@@ -1258,11 +1291,44 @@ export class BookingTransitionService {
|
||||
// batch runs after the window closes + staff document review, never at accept
|
||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||
// by the periodic legacy fill.)
|
||||
//
|
||||
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
|
||||
// no pay window exists to wait for — accept places them straight onto
|
||||
// their company's dedicated train and its wagons. Non-fatal on purpose:
|
||||
// the accept has committed; an allocation hiccup leaves the booking in
|
||||
// the day pool for the batch engine / staff instead of failing the accept.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
try {
|
||||
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const trainFresh = await this.bookingsService.findById(booking.id);
|
||||
this.emitShippingLineAccepted(trainFresh);
|
||||
this.notifier.operationAccepted(trainFresh);
|
||||
return trainFresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping-line booking becomes debt at THIS moment — Operations accepted
|
||||
* it — not at completion/pricing. Event, not a service call:
|
||||
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
|
||||
* importing its module here would close a module cycle. Emitted after the
|
||||
* accept has fully committed (including the export-capacity path, which can
|
||||
* still revert the status above), so a failed accept never creates debt.
|
||||
*/
|
||||
private emitShippingLineAccepted(booking: Booking): void {
|
||||
if (!booking.shippingLineCompanyId) return;
|
||||
this.events.emit("shipping_line_booking.accepted", {
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
amount: Number(booking.totalAmount),
|
||||
} satisfies ShippingLineBookingAcceptedPayload);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
|
||||
@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
|
||||
*/
|
||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||
|
||||
/**
|
||||
* The document set a shipping line uploads on a booking it initiated.
|
||||
*
|
||||
* Shipping lines book without a contract, so none of the trade-direction /
|
||||
* freight / customs matrix below applies to them — this one admin-configured
|
||||
* set is what Operations reviews before the booking may be completed.
|
||||
*/
|
||||
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
|
||||
'shipping_line_booking_documents';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
// Shipping-line bookings resolve to their own single set and never reach the
|
||||
// matrix below: they have no contract, and their trade direction / freight
|
||||
// type are placeholders until the booking is completed, so the customer codes
|
||||
// would resolve to a set that was never meant for them. Keyed off the owner
|
||||
// column, which is NULL on every customer booking — so no customer booking
|
||||
// can take this branch.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
return {
|
||||
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
|
||||
outputCode: null,
|
||||
includesCustoms: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||
// created with customsClearingEnabled (copied from the contract). Contract
|
||||
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||
|
||||
@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
// Every booking is billed to a company — government bookings bill to a seeded
|
||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
// Every CUSTOMER booking is billed to a company — government bookings bill to
|
||||
// a seeded government company (companies.kind = 'government'). NULL only on a
|
||||
// shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK
|
||||
// enforces that exactly one of the two is set.
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The shipping-line ACCOUNT that owns this booking, when it is not a
|
||||
* customer's. Shipping lines book without a contract and are not `companies`
|
||||
* rows (no TIN, licence or operational profiles), so they get their own owner
|
||||
* column rather than a synthetic company. NULL on every customer booking.
|
||||
*
|
||||
* Deliberately NOT `shippingLineId` above: that is cargo metadata naming the
|
||||
* carrier line that moves the goods (`freight.shipping_lines`, reference data
|
||||
* set on customer bookings too). This points at `shipping_line_companies` —
|
||||
* the portal account — and the two are unrelated.
|
||||
*
|
||||
* No relation is declared: `ShippingLineCompany` lives in its own module and
|
||||
* the column is read by id, matching how the migration leaves it FK-free.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
/**
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
@@ -125,7 +144,9 @@ export class Booking extends BaseEntity {
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
// NULL only on a shipping-line booking — shipping lines have no operational
|
||||
// profiles. Always set on a customer booking, as before.
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
} from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -96,6 +101,7 @@ export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -119,16 +125,31 @@ export class CompaniesController {
|
||||
|
||||
@Get("getInfo")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
@ApiOperation({
|
||||
summary: "Get account info for the current user (customer or shipping line)",
|
||||
})
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
if (shippingLine) {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company, review);
|
||||
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
|
||||
accountKind: "customer" as const,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -44,6 +45,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
// Fayda identity verification for the company's owner and PoA.
|
||||
VerifaydaModule,
|
||||
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
||||
// shipping-line session, which has no company row to look up. forwardRef
|
||||
// because that module imports BillingModule, which imports this one.
|
||||
forwardRef(() => ShippingLineCompaniesModule),
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
* What kind of account is signed in to the portal.
|
||||
*
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@ApiProperty({ enum: ["shipping_line"] })
|
||||
accountKind: "shipping_line" = "shipping_line";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
/**
|
||||
* Always null. Present so the portal can read `company` / `profile` off either
|
||||
* payload shape without narrowing the union first — the fields a customer
|
||||
* session carries simply have no shipping-line equivalent.
|
||||
*/
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.status = entity.status;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
@@ -100,6 +100,7 @@ export class EimsCancellationService {
|
||||
|
||||
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
|
||||
private async notifyBuyer(invoice: Invoice): Promise<void> {
|
||||
if (!invoice.companyId) return;
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
|
||||
@@ -394,7 +394,7 @@ export class EimsInvoiceRegistrationService {
|
||||
let invoiceNumber = invoiceId;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
companyId = invoice.companyId;
|
||||
companyId = invoice.companyId ?? undefined;
|
||||
invoiceNumber = invoice.invoiceNumber;
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
|
||||
@@ -219,6 +219,7 @@ export class EimsReceiptService {
|
||||
}
|
||||
|
||||
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
|
||||
if (!invoice.companyId) return;
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
|
||||
/**
|
||||
* Notification target for a shipping-line booking.
|
||||
*
|
||||
* A shipping line is NOT a `companies` row: the company IS the account — one
|
||||
* IAM user (`userId`), and the contact details live on the
|
||||
* `shipping_line_companies` row itself. So the customer resolvers
|
||||
* (external_profiles fan-out, company attributes email) never apply; this is
|
||||
* the one lookup every shipping-line notification routes through.
|
||||
*
|
||||
* Entity-only import — safe from any module graph: notifiers already own a
|
||||
* DataSource and need no service from the shipping-lines module.
|
||||
*/
|
||||
export async function resolveShippingLineNotifyTarget(
|
||||
dataSource: DataSource,
|
||||
shippingLineCompanyId: string,
|
||||
): Promise<{
|
||||
userId: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}> {
|
||||
const line = await dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { id: shippingLineCompanyId } });
|
||||
return {
|
||||
userId: line?.userId ?? null,
|
||||
phone: line?.phoneNumber ?? null,
|
||||
email: line?.email ?? null,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export class OverviewContractKpisDto {
|
||||
export class OverviewOperationsKpisDto {
|
||||
@ApiProperty() trainsActive!: number;
|
||||
@ApiProperty() wagonsAvailable!: number;
|
||||
@ApiProperty() wagonsTotal!: number;
|
||||
@ApiProperty() containersInTransit!: number;
|
||||
@ApiProperty() cargoesLoaded!: number;
|
||||
@ApiProperty() schedulesUpcoming!: number;
|
||||
@@ -108,6 +109,41 @@ export class OverviewRecentContractDto {
|
||||
@ApiProperty() createdAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewPeriodTotalsDto {
|
||||
@ApiProperty() bookingsCreated!: number;
|
||||
@ApiProperty() revenueEtb!: number;
|
||||
@ApiProperty() revenueUsd!: number;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewRevenueSliceDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewTonsTrendPointDto {
|
||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewRevenueFlowDto {
|
||||
@ApiProperty() direction!: string;
|
||||
@ApiProperty() freightType!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewHeatmapCellDto {
|
||||
@ApiProperty({ description: 'ISO weekday, 1 = Monday … 7 = Sunday' })
|
||||
dow!: number;
|
||||
|
||||
@ApiProperty({ description: '3-hour block, 0 = 00–03 … 7 = 21–24' })
|
||||
block!: number;
|
||||
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewResponseDto {
|
||||
@ApiProperty({ type: OverviewKpisDto })
|
||||
kpis!: OverviewKpisDto;
|
||||
@@ -124,8 +160,29 @@ export class OverviewResponseDto {
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
paymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRecentBookingDto] })
|
||||
recentBookings!: OverviewRecentBookingDto[];
|
||||
@ApiProperty({ type: OverviewPeriodTotalsDto })
|
||||
current!: OverviewPeriodTotalsDto;
|
||||
|
||||
@ApiProperty({ type: OverviewPeriodTotalsDto })
|
||||
previous!: OverviewPeriodTotalsDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueSliceDto] })
|
||||
revenueByDirection!: OverviewRevenueSliceDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueSliceDto] })
|
||||
revenueByFreightType!: OverviewRevenueSliceDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
previousPaymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTonsTrendPointDto] })
|
||||
tonsTrend!: OverviewTonsTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueFlowDto] })
|
||||
revenueFlows!: OverviewRevenueFlowDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewHeatmapCellDto] })
|
||||
bookingHeatmap!: OverviewHeatmapCellDto[];
|
||||
|
||||
@ApiProperty() generatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,28 @@ export class OverviewTonnagePointDto {
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
/** One bucket × series cell of a two-dimensional breakdown. */
|
||||
export class OverviewMatrixCellDto {
|
||||
@ApiProperty({ example: 'Flat wagon' }) group!: string;
|
||||
@ApiProperty({ example: 'AVAILABLE' }) series!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewTrainLoadDto {
|
||||
@ApiProperty() scheduleId!: string;
|
||||
@ApiProperty({ example: '8001' }) trainNumber!: string;
|
||||
@ApiProperty({ example: '2026-08-13' }) date!: string;
|
||||
@ApiProperty({ example: 'EXPORT' }) direction!: string;
|
||||
@ApiProperty() wagonsAllocated!: number;
|
||||
@ApiProperty() wagonsTotal!: number;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewTurnaroundDto {
|
||||
@ApiProperty({ example: '8001' }) trainSet!: string;
|
||||
@ApiProperty({ example: 26.5 }) hours!: number;
|
||||
}
|
||||
|
||||
export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
kpis!: OverviewOperationsKpisDto;
|
||||
@@ -150,6 +172,64 @@ export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
cargoStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
bookingsByPort!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
bookingStatusByPort!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTrainLoadDto] })
|
||||
trainLoads!: OverviewTrainLoadDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewFleetTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
wagonStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
wagonStatusByType!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
wagonStatusByYard!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
locomotiveStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
locomotivesByYard!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
locomotivesByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ nullable: true, example: 26.5 })
|
||||
avgTurnaroundHours!: number | null;
|
||||
|
||||
@ApiProperty({ type: [OverviewTurnaroundDto] })
|
||||
turnaroundByTrain!: OverviewTurnaroundDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewClearanceTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
bookingDocumentsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
contractDocumentsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
invoicesByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
invoicesByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
handoversByMile!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
@@ -167,6 +247,9 @@ export class OverviewCustomersTabDto {
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
topCustomersByBookings!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
profilesByTypeStatus!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewClearanceTabDto,
|
||||
OverviewContractsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewFleetTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
@@ -106,6 +108,22 @@ export class OverviewController {
|
||||
return this.overviewService.getOperationsTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('fleet')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Wagon and locomotive fleet detail, plus turnaround' })
|
||||
@ApiOkResponse({ type: OverviewFleetTabDto })
|
||||
getFleetTab(@Query() query: OverviewQueryDto): Promise<OverviewFleetTabDto> {
|
||||
return this.overviewService.getFleetTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Document review and invoice queues' })
|
||||
@ApiOkResponse({ type: OverviewClearanceTabDto })
|
||||
getClearanceTab(): Promise<OverviewClearanceTabDto> {
|
||||
return this.overviewService.getClearanceTab();
|
||||
}
|
||||
|
||||
@Get('customers')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||
|
||||
@@ -58,6 +58,21 @@ export type OverviewRecentBookingRow = {
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/** One cell of a two-dimensional breakdown (bucket × stacked series). */
|
||||
export type MatrixCell = { group: string; series: string; count: number };
|
||||
|
||||
export type TurnaroundRow = { trainSet: string; hours: number };
|
||||
|
||||
export type TrainLoadRow = {
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
date: string;
|
||||
direction: string;
|
||||
wagonsAllocated: number;
|
||||
wagonsTotal: number;
|
||||
tons: number;
|
||||
};
|
||||
|
||||
export type OverviewContractKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
@@ -155,6 +170,7 @@ export class OverviewRepository {
|
||||
async getOperationsKpis(): Promise<{
|
||||
trainsActive: number;
|
||||
wagonsAvailable: number;
|
||||
wagonsTotal: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
schedulesUpcoming: number;
|
||||
@@ -163,6 +179,7 @@ export class OverviewRepository {
|
||||
const [
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
wagonsTotal,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
@@ -185,6 +202,10 @@ export class OverviewRepository {
|
||||
status: Freight.WagonStatus.Available,
|
||||
})
|
||||
.getCount(),
|
||||
this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.getCount(),
|
||||
this.containerRepository
|
||||
.createQueryBuilder("container")
|
||||
.where("container.deleted_at IS NULL")
|
||||
@@ -218,6 +239,7 @@ export class OverviewRepository {
|
||||
return {
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
wagonsTotal,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
@@ -345,9 +367,16 @@ export class OverviewRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily successful-payment revenue for a `days`-wide window shifted back by
|
||||
* `offsetDays` — `0` (default) is the current window ending today,
|
||||
* `offsetDays: days` is the immediately preceding window (the ghost-line
|
||||
* comparison series on the overview chart).
|
||||
*/
|
||||
async getPaymentTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
offsetDays = 0,
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
@@ -366,8 +395,8 @@ export class OverviewRepository {
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
|
||||
{ days, offsetDays },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
@@ -546,6 +575,247 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings created, revenue and tonnage for one `days`-wide window, shifted
|
||||
* back by `offsetDays`. Called twice by the service — `offsetDays: 0` for
|
||||
* the current period, `offsetDays: days` for the immediately preceding
|
||||
* one-of-the-same-length period — so the page can show a real vs-prior-period
|
||||
* delta instead of a bare count.
|
||||
*/
|
||||
async getPeriodTotals(
|
||||
days: number,
|
||||
offsetDays: number,
|
||||
dirs?: string[],
|
||||
): Promise<{
|
||||
bookingsCreated: number;
|
||||
revenueEtb: number;
|
||||
revenueUsd: number;
|
||||
tons: number;
|
||||
}> {
|
||||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const paymentScope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const cargoScope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const windowSql = (column: string) =>
|
||||
`${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`;
|
||||
|
||||
const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([
|
||||
this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(bookingScope.sql, bookingScope.params)
|
||||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||||
.getCount(),
|
||||
this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"revenueEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"revenueUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
||||
{ days, offsetDays },
|
||||
)
|
||||
.andWhere(paymentScope.sql, paymentScope.params)
|
||||
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||
.select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere(windowSql("cargo.created_at"), { days, offsetDays })
|
||||
.andWhere(cargoScope.sql, cargoScope.params)
|
||||
.getRawOne<{ tons: string }>(),
|
||||
]);
|
||||
|
||||
return {
|
||||
bookingsCreated,
|
||||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||||
tons: Number(tonsRow?.tons ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Revenue for the selected range, split by booking trade direction. */
|
||||
async getRevenueByDirection(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.trade_direction", "label")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.trade_direction IS NOT NULL")
|
||||
.groupBy("booking.trade_direction")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Revenue for the selected range, split by booking freight type. */
|
||||
async getRevenueByFreightType(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.freight_type IS NOT NULL")
|
||||
.groupBy("booking.freight_type")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Daily cargo tonnage for the selected range — hero sparkline series. */
|
||||
async getTonsTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ date: string; tons: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||
.select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("cargo.created_at::date")
|
||||
.orderBy("cargo.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; tons: string }>();
|
||||
|
||||
return rows.map((row) => ({ date: row.date, tons: Number(row.tons) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revenue for the selected range as direction → freight-type flows — the
|
||||
* Sankey on the overview. One row per (direction, freight type) pair.
|
||||
*/
|
||||
async getRevenueFlows(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<
|
||||
{
|
||||
direction: string;
|
||||
freightType: string;
|
||||
amountEtb: number;
|
||||
amountUsd: number;
|
||||
}[]
|
||||
> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.trade_direction", "direction")
|
||||
.addSelect("booking.freight_type", "freightType")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.trade_direction IS NOT NULL")
|
||||
.andWhere("booking.freight_type IS NOT NULL")
|
||||
.groupBy("booking.trade_direction")
|
||||
.addGroupBy("booking.freight_type")
|
||||
.getRawMany<{
|
||||
direction: string;
|
||||
freightType: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
direction: row.direction,
|
||||
freightType: row.freightType,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour
|
||||
* block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use
|
||||
* the database server's timezone, same as every ::date grouping here.
|
||||
*/
|
||||
async getBookingHeatmap(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ dow: number; block: number; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow")
|
||||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||||
.addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int")
|
||||
.getRawMany<{ dow: string; block: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
dow: Number(row.dow),
|
||||
block: Number(row.block),
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTrainStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
@@ -1006,4 +1276,334 @@ export class OverviewRepository {
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fleet / operations detail. These read tables that have no repository
|
||||
// injected here (locomotives, train_set_wagons, document reviews, …), so they
|
||||
// go through the shared entity manager with plain SQL instead of adding six
|
||||
// more constructor arguments for one query each.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private get sql() {
|
||||
return this.wagonRepository.manager;
|
||||
}
|
||||
|
||||
private async matrix(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<MatrixCell[]> {
|
||||
const rows = await this.sql.query<
|
||||
{ group: string; series: string; count: string }[]
|
||||
>(statement, params);
|
||||
return rows.map((row) => ({
|
||||
group: row.group,
|
||||
series: row.series,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
private async labelCounts(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.sql.query<{ label: string; count: string }[]>(
|
||||
statement,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
private async statusCounts(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.sql.query<{ status: string; count: string }[]>(
|
||||
statement,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({ status: row.status, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
/** Wagon lifecycle state crossed with wagon type — "how many flat wagons are detained". */
|
||||
getWagonStatusByType(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(wt.name, 'Unknown') AS "group",
|
||||
w.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.wagons w
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
/** Same lifecycle state, per yard the wagon currently sits in. */
|
||||
getWagonStatusByYard(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT y.label AS "group",
|
||||
w.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.wagons w
|
||||
INNER JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
getLocomotiveStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.locomotives
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/** Locomotives per station, split by status — the OCC's first question. */
|
||||
getLocomotivesByYard(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(y.label, 'Unassigned') AS "group",
|
||||
l.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.locomotives l
|
||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
getLocomotivesByType(): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(`
|
||||
SELECT COALESCE(locomotive_type, 'Unknown') AS label,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.locomotives
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Departure-to-departure gap per train set over the range. Only consecutive
|
||||
* *actual* departures count — a schedule that never left says nothing about
|
||||
* how fast the set turned around.
|
||||
*/
|
||||
async getTrainTurnaround(
|
||||
days: number,
|
||||
limit: number,
|
||||
): Promise<{ avgHours: number | null; rows: TurnaroundRow[] }> {
|
||||
const rows = await this.sql.query<
|
||||
{ trainSet: string; hours: string }[]
|
||||
>(
|
||||
`
|
||||
WITH departures AS (
|
||||
SELECT s.train_set_id,
|
||||
s.actual_departure_at,
|
||||
LAG(s.actual_departure_at) OVER (
|
||||
PARTITION BY s.train_set_id ORDER BY s.actual_departure_at
|
||||
) AS previous_departure
|
||||
FROM freight.train_schedules s
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.actual_departure_at IS NOT NULL
|
||||
AND s.actual_departure_at >= NOW() - make_interval(days => $1::int)
|
||||
)
|
||||
SELECT COALESCE(t.train_number, t.code, 'Train set') AS "trainSet",
|
||||
ROUND(
|
||||
AVG(
|
||||
EXTRACT(EPOCH FROM (d.actual_departure_at - d.previous_departure)) / 3600
|
||||
)::numeric,
|
||||
1
|
||||
) AS hours
|
||||
FROM departures d
|
||||
LEFT JOIN freight.train_sets ts ON ts.id = d.train_set_id
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE d.previous_departure IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY hours ASC
|
||||
LIMIT $2::int
|
||||
`,
|
||||
[days, limit],
|
||||
);
|
||||
|
||||
const mapped = rows.map((row) => ({
|
||||
trainSet: row.trainSet,
|
||||
hours: Number(row.hours),
|
||||
}));
|
||||
const avgHours = mapped.length
|
||||
? Number(
|
||||
(
|
||||
mapped.reduce((sum, row) => sum + row.hours, 0) / mapped.length
|
||||
).toFixed(1),
|
||||
)
|
||||
: null;
|
||||
|
||||
return { avgHours, rows: mapped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings per port yard. Import cargo enters at its origin yard, export
|
||||
* cargo leaves from its destination yard — anything else is counted at origin.
|
||||
*/
|
||||
getBookingsByPort(days: number): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(
|
||||
`
|
||||
SELECT y.label AS label, COUNT(*)::int AS count
|
||||
FROM freight.bookings b
|
||||
INNER JOIN freight.yards y
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
`,
|
||||
[days],
|
||||
);
|
||||
}
|
||||
|
||||
getBookingStatusByPort(days: number): Promise<MatrixCell[]> {
|
||||
return this.matrix(
|
||||
`
|
||||
SELECT y.label AS "group", b.status AS series, COUNT(*)::int AS count
|
||||
FROM freight.bookings b
|
||||
INNER JOIN freight.yards y
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`,
|
||||
[days],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon fill and tonnage per scheduled train. Slots come from the train set's
|
||||
* wagon list, the load from confirmed booking allocations against those slots.
|
||||
*/
|
||||
async getTrainLoads(limit: number): Promise<TrainLoadRow[]> {
|
||||
const rows = await this.sql.query<
|
||||
{
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
date: string;
|
||||
direction: string;
|
||||
wagonsTotal: string;
|
||||
wagonsAllocated: string;
|
||||
tons: string;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
SELECT s.id AS "scheduleId",
|
||||
COALESCE(s.train_number, s.reference, '—') AS "trainNumber",
|
||||
to_char(s.scheduled_departure_date, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(s.direction, 'DOMESTIC') AS direction,
|
||||
COALESCE(slots.total, 0)::int AS "wagonsTotal",
|
||||
COALESCE(load.wagons, 0)::int AS "wagonsAllocated",
|
||||
COALESCE(load.tons, 0)::float AS tons
|
||||
FROM freight.train_schedules s
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM freight.train_set_wagons tsw
|
||||
WHERE tsw.train_set_id = s.train_set_id
|
||||
AND tsw.deleted_at IS NULL
|
||||
) slots ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(DISTINCT wba.train_set_wagon_id)::int AS wagons,
|
||||
COALESCE(SUM(wba.allocated_weight_tons), 0) AS tons
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
WHERE tsw.train_set_id = s.train_set_id
|
||||
AND wba.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
) load ON TRUE
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.status <> 'DRAFT'
|
||||
ORDER BY s.scheduled_departure_date DESC
|
||||
LIMIT $1::int
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
scheduleId: row.scheduleId,
|
||||
trainNumber: row.trainNumber,
|
||||
date: row.date,
|
||||
direction: row.direction,
|
||||
wagonsTotal: Number(row.wagonsTotal),
|
||||
wagonsAllocated: Number(row.wagonsAllocated),
|
||||
tons: Number(row.tons),
|
||||
}));
|
||||
}
|
||||
|
||||
getBookingDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.booking_document_review
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getContractDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.contract_document_review
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getInvoicesByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.invoices
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getInvoicesByType(): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(`
|
||||
SELECT COALESCE(type, 'Other') AS label, COUNT(*)::int AS count
|
||||
FROM freight.invoices
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/** Handover papers per mile, split into signed and awaiting signature. */
|
||||
getHandoversByMile(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(mile_type, 'Unknown') AS "group",
|
||||
CASE WHEN signed_at IS NULL THEN 'PENDING' ELSE 'SIGNED' END AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.booking_handovers
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
/** Company profiles per type × status — active, pending and suspended per trade role. */
|
||||
getProfilesByTypeStatus(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT type AS "group", status AS series, COUNT(*)::int AS count
|
||||
FROM freight.company_profiles
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import type { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import type {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewClearanceTabDto,
|
||||
OverviewContractsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewFleetTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
@@ -56,7 +58,14 @@ export class OverviewService {
|
||||
bookingTrend,
|
||||
statusCounts,
|
||||
paymentTrend,
|
||||
recentBookings,
|
||||
current,
|
||||
previous,
|
||||
revenueByDirection,
|
||||
revenueByFreightType,
|
||||
previousPaymentTrend,
|
||||
tonsTrend,
|
||||
revenueFlows,
|
||||
bookingHeatmap,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(dirs),
|
||||
this.overviewRepository.getContractKpis(dirs),
|
||||
@@ -67,7 +76,14 @@ export class OverviewService {
|
||||
this.overviewRepository.getBookingTrend(days, dirs),
|
||||
this.overviewRepository.getStatusCounts(dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||
this.overviewRepository.getRecentBookings(8, dirs),
|
||||
this.overviewRepository.getPeriodTotals(days, 0, dirs),
|
||||
this.overviewRepository.getPeriodTotals(days, days, dirs),
|
||||
this.overviewRepository.getRevenueByDirection(days, dirs),
|
||||
this.overviewRepository.getRevenueByFreightType(days, dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs, days),
|
||||
this.overviewRepository.getTonsTrend(days, dirs),
|
||||
this.overviewRepository.getRevenueFlows(days, dirs),
|
||||
this.overviewRepository.getBookingHeatmap(days, dirs),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
@@ -86,10 +102,14 @@ export class OverviewService {
|
||||
bookingsByStatus,
|
||||
bookingsByPipeline,
|
||||
paymentTrend,
|
||||
recentBookings: recentBookings.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
current,
|
||||
previous,
|
||||
revenueByDirection,
|
||||
revenueByFreightType,
|
||||
previousPaymentTrend,
|
||||
tonsTrend,
|
||||
revenueFlows,
|
||||
bookingHeatmap,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -219,6 +239,9 @@ export class OverviewService {
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
bookingsByPort,
|
||||
bookingStatusByPort,
|
||||
trainLoads,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getDepartureTrend(days),
|
||||
@@ -231,6 +254,9 @@ export class OverviewService {
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getContainerStatusBreakdown(),
|
||||
this.overviewRepository.getCargoStatusBreakdown(),
|
||||
this.overviewRepository.getBookingsByPort(days),
|
||||
this.overviewRepository.getBookingStatusByPort(days),
|
||||
this.overviewRepository.getTrainLoads(8),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -245,6 +271,76 @@ export class OverviewService {
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
bookingsByPort,
|
||||
bookingStatusByPort,
|
||||
trainLoads,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling stock in depth: wagons and locomotives crossed with type and yard,
|
||||
* plus how fast each train set turns around. Feeds the operations and control
|
||||
* centre dashboards.
|
||||
*/
|
||||
async getFleetTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
): Promise<OverviewFleetTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
wagonStatusBreakdown,
|
||||
wagonStatusByType,
|
||||
wagonStatusByYard,
|
||||
locomotiveStatusBreakdown,
|
||||
locomotivesByYard,
|
||||
locomotivesByType,
|
||||
turnaround,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getWagonStatusByType(),
|
||||
this.overviewRepository.getWagonStatusByYard(),
|
||||
this.overviewRepository.getLocomotiveStatusBreakdown(),
|
||||
this.overviewRepository.getLocomotivesByYard(),
|
||||
this.overviewRepository.getLocomotivesByType(),
|
||||
this.overviewRepository.getTrainTurnaround(days, 8),
|
||||
]);
|
||||
|
||||
return {
|
||||
wagonStatusBreakdown,
|
||||
wagonStatusByType,
|
||||
wagonStatusByYard,
|
||||
locomotiveStatusBreakdown,
|
||||
locomotivesByYard,
|
||||
locomotivesByType,
|
||||
avgTurnaroundHours: turnaround.avgHours,
|
||||
turnaroundByTrain: turnaround.rows,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Document and invoice queues — the GL desks' and marketing's work in progress. */
|
||||
async getClearanceTab(): Promise<OverviewClearanceTabDto> {
|
||||
const [
|
||||
bookingDocumentsByStatus,
|
||||
contractDocumentsByStatus,
|
||||
invoicesByStatus,
|
||||
invoicesByType,
|
||||
handoversByMile,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingDocumentsByStatus(),
|
||||
this.overviewRepository.getContractDocumentsByStatus(),
|
||||
this.overviewRepository.getInvoicesByStatus(),
|
||||
this.overviewRepository.getInvoicesByType(),
|
||||
this.overviewRepository.getHandoversByMile(),
|
||||
]);
|
||||
|
||||
return {
|
||||
bookingDocumentsByStatus,
|
||||
contractDocumentsByStatus,
|
||||
invoicesByStatus,
|
||||
invoicesByType,
|
||||
handoversByMile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -255,19 +351,26 @@ export class OverviewService {
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
||||
await Promise.all([
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||
this.overviewRepository.getCustomersByType(),
|
||||
this.overviewRepository.getTopCustomersByBookings(8, dirs),
|
||||
]);
|
||||
const [
|
||||
kpis,
|
||||
customerGrowthTrend,
|
||||
customersByType,
|
||||
topCustomersByBookings,
|
||||
profilesByTypeStatus,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||
this.overviewRepository.getCustomersByType(),
|
||||
this.overviewRepository.getTopCustomersByBookings(8, dirs),
|
||||
this.overviewRepository.getProfilesByTypeStatus(),
|
||||
]);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
customerGrowthTrend,
|
||||
customersByType,
|
||||
topCustomersByBookings,
|
||||
profilesByTypeStatus,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ export class CreateRateDto {
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...CURRENCIES])
|
||||
|
||||
@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
trigger?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter to one shipping line\'s rates.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
isShippingLineRate?: boolean;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { CargoType } from './cargo-type.entity';
|
||||
import { ContainerType } from './container-type.entity';
|
||||
import { Yard } from './yard.entity';
|
||||
@@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
@Index(['trigger'])
|
||||
@Index(['originYardId'])
|
||||
@Index(['destinationYardId'])
|
||||
@Index(['shippingLineCompanyId'])
|
||||
export class Rate extends BaseEntity {
|
||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||
rateType!: RateType;
|
||||
@@ -155,6 +157,23 @@ export class Rate extends BaseEntity {
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard | null;
|
||||
|
||||
/**
|
||||
* The shipping line this rate belongs to, or NULL for the standard rate every
|
||||
* customer pays. A booking owned by a shipping line prices exclusively off
|
||||
* that line's rates — the standard rate is NOT a fallback, so a missing line
|
||||
* rate hard-blocks the booking rather than quietly billing the customer price.
|
||||
*
|
||||
* Points at `shipping_line_companies` (the portal account that books capacity),
|
||||
* not `shipping_lines` (carrier reference data behind the SHIPPING_LINE
|
||||
* trigger). The two are unrelated despite the similar names.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'shipping_line_company_id' })
|
||||
shippingLineCompany?: ShippingLineCompany | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface IRatesRepository {
|
||||
rateType: string;
|
||||
/** Omitted for singly-resolved rates — see the repository implementation. */
|
||||
rateUnit?: string;
|
||||
/** Owning shipping line; null/omitted = the standard customer rate. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
|
||||
@@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit?: string;
|
||||
shippingLineCompanyId?: string | null;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository {
|
||||
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
|
||||
}
|
||||
|
||||
// The owner is part of the identity: a line's rate for a lane is a
|
||||
// different rate from the standard one, not a duplicate of it.
|
||||
if (pattern.shippingLineCompanyId) {
|
||||
qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', {
|
||||
shippingLineCompanyId: pattern.shippingLineCompanyId,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('rate.shipping_line_company_id IS NULL');
|
||||
}
|
||||
|
||||
if (pattern.containerTypeId) {
|
||||
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
||||
} else {
|
||||
@@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository {
|
||||
// yards joined the route columns have only ids to render.
|
||||
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
||||
// The shipping-line tab renders the owning line's name, not its id.
|
||||
.leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany')
|
||||
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||
|
||||
if (query.shippingLineCompanyId) {
|
||||
qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', {
|
||||
shippingLineCompanyId: query.shippingLineCompanyId,
|
||||
});
|
||||
} else if (query.isShippingLineRate !== undefined) {
|
||||
// Tab filter: shipping-line rates (any line) vs standard customer rates.
|
||||
qb.andWhere(
|
||||
query.isShippingLineRate
|
||||
? 'rate.shippingLineCompanyId IS NOT NULL'
|
||||
: 'rate.shippingLineCompanyId IS NULL',
|
||||
);
|
||||
}
|
||||
if (query.status) {
|
||||
qb.andWhere('rate.status = :status', { status: query.status });
|
||||
}
|
||||
@@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module';
|
||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
@@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
|
||||
// against them (a cap above the rating is a typo, not a policy).
|
||||
WagonTypesModule,
|
||||
// Rates may be scoped to one shipping line; creating such a rate validates
|
||||
// the line exists and is active.
|
||||
ShippingLineCompaniesModule,
|
||||
],
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
|
||||
@@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — shipping-line rates override the standard ones', () => {
|
||||
const LINE = 'slc-msc';
|
||||
|
||||
/** Standard customer container-import rate on the lane. */
|
||||
const standardBase: Rate = {
|
||||
id: 'rate-standard-20',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
trigger: 'ALWAYS',
|
||||
rateValue: 1000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
/** The same lane, priced for one shipping line. */
|
||||
const lineBase: Rate = {
|
||||
...standardBase,
|
||||
id: 'rate-line-20',
|
||||
rateValue: 1200,
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
const standardHazard: Rate = {
|
||||
id: 'rate-hazard-standard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
} as Rate;
|
||||
|
||||
const lineHazard: Rate = {
|
||||
...standardHazard,
|
||||
id: 'rate-hazard-line',
|
||||
rateValue: 80,
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[]) =>
|
||||
new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||||
const bookingInput = (
|
||||
overrides: Partial<BookingEvaluationInput> = {},
|
||||
): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
|
||||
it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => {
|
||||
const result = await buildService([standardBase, lineBase]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE }),
|
||||
);
|
||||
const ow = overweightOf(result);
|
||||
expect(ow).toHaveLength(1);
|
||||
// The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t.
|
||||
expect(ow[0]).toMatchObject({
|
||||
rateId: lineBase.id,
|
||||
unitPriceUsd: 30,
|
||||
calculatedAmount: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a customer booking on the standard rate even when a line rate exists', async () => {
|
||||
const result = await buildService([standardBase, lineBase]).evaluate(bookingInput());
|
||||
const ow = overweightOf(result);
|
||||
expect(ow).toHaveLength(1);
|
||||
expect(ow[0]).toMatchObject({
|
||||
rateId: standardBase.id,
|
||||
unitPriceUsd: 25,
|
||||
calculatedAmount: 125,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fall back to the standard rate when the line has none for the lane', async () => {
|
||||
const result = await buildService([standardBase]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE }),
|
||||
);
|
||||
// No line rate on the lane → nothing to derive from. Base freight is what
|
||||
// hard-blocks the booking; the standard 1000 must never be borrowed here.
|
||||
expect(overweightOf(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('bills the line\'s own surcharge and never the standard one alongside it', async () => {
|
||||
const result = await buildService([
|
||||
standardBase,
|
||||
lineBase,
|
||||
standardHazard,
|
||||
lineHazard,
|
||||
]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }));
|
||||
|
||||
const hazard = result.appliedModifiers.filter(
|
||||
(m) => m.surchargeCode === 'HAZARD_SURCHARGE',
|
||||
);
|
||||
expect(hazard).toHaveLength(1);
|
||||
expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 });
|
||||
});
|
||||
|
||||
it('hard-blocks a requested service the line has no surcharge rate for', async () => {
|
||||
const result = await buildService([standardBase, lineBase, standardHazard]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }),
|
||||
);
|
||||
// The standard hazard rate exists but belongs to customers, so the line's
|
||||
// hazardous booking must block rather than borrow it.
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,16 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* The shipping line that OWNS this booking (`bookings.shipping_line_company_id`),
|
||||
* when it is a shipping-line booking rather than a customer one. Such a booking
|
||||
* prices exclusively off that line's own rates — see {@link ratesForOwner}.
|
||||
*
|
||||
* Not to be confused with `shippingLineId` above, which is cargo metadata
|
||||
* naming the carrier that physically moves the goods and only feeds the
|
||||
* SHIPPING_LINE double-handling trigger.
|
||||
*/
|
||||
shippingLineCompanyId?: string | null;
|
||||
/**
|
||||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||
* this route's own container freight rate, so the engine needs the yards.
|
||||
@@ -282,7 +292,10 @@ export class RuleEngineService {
|
||||
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
|
||||
// from a non-idempotent seeder — would otherwise repeat the same surcharge
|
||||
// many times and inflate the total, so we collapse them to one row each.
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const liveRates = this.ratesForOwner(
|
||||
await this.ratesRepo.findLiveRates(),
|
||||
input.shippingLineCompanyId,
|
||||
);
|
||||
const surchargeRates = this.dedupeRatesBySignature(
|
||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||
);
|
||||
@@ -812,6 +825,28 @@ export class RuleEngineService {
|
||||
return rate.rateType ?? rate.trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the LIVE rate pool to the ones this booking's owner may price off.
|
||||
*
|
||||
* A customer booking sees only standard rates (no owner) — a shipping line's
|
||||
* negotiated price must never leak into a customer quote. A shipping-line
|
||||
* booking sees only that line's own rates: line rates OVERRIDE the standard
|
||||
* ones rather than stacking on them, and the standard rate is deliberately
|
||||
* NOT a fallback, so a lane the line has no rate for hard-blocks downstream
|
||||
* (base freight already blocks on "no rate for this route") instead of
|
||||
* quietly billing the line at the customer price.
|
||||
*
|
||||
* Filtering once, here, is what makes the override apply uniformly: every
|
||||
* downstream lookup (base freight, derived overweight, empty return, lashing,
|
||||
* fuel, and the additive surcharges) reads from this same pool, so none of
|
||||
* them needs its own owner check.
|
||||
*/
|
||||
private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] {
|
||||
return shippingLineCompanyId
|
||||
? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId)
|
||||
: rates.filter((r) => !r.shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse rates that describe the same charge to a single representative.
|
||||
*
|
||||
|
||||
@@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => {
|
||||
})),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
// Shipping line companies — these rates carry no owner, so it is never hit.
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
@@ -39,6 +41,7 @@ export class RatesService {
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
@@ -491,6 +494,7 @@ export class RatesService {
|
||||
rateType: string;
|
||||
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
||||
rateUnit?: string;
|
||||
shippingLineCompanyId: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -508,6 +512,35 @@ export class RatesService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the shipping line a rate is scoped to, when any.
|
||||
*
|
||||
* A shipping line only ever ships import — the export leg is sold through the
|
||||
* customer's contract — so a line rate carrying an EXPORT direction is
|
||||
* rejected here as well as by `CK_rates_shipping_line_import_only`.
|
||||
* Returns the owner id to store (null = the standard customer rate).
|
||||
*/
|
||||
private async resolveShippingLineScope(
|
||||
shippingLineCompanyId: string | null | undefined,
|
||||
tradeDirection: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!shippingLineCompanyId) return null;
|
||||
|
||||
// Throws NotFoundException when the line does not exist.
|
||||
const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId);
|
||||
if (line.status !== ShippingLineStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
`${line.name} is ${line.status} — rates can only be configured for an active shipping line.`,
|
||||
);
|
||||
}
|
||||
if (tradeDirection && tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'Shipping line rates are import-only — the export leg is priced through the customer contract.',
|
||||
);
|
||||
}
|
||||
return shippingLineCompanyId;
|
||||
}
|
||||
|
||||
/** Create a rate in DRAFT status. */
|
||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
||||
@@ -568,6 +601,11 @@ export class RatesService {
|
||||
destinationYardId: dto.destinationYardId,
|
||||
});
|
||||
|
||||
const shippingLineCompanyId = await this.resolveShippingLineScope(
|
||||
dto.shippingLineCompanyId,
|
||||
tradeDirection,
|
||||
);
|
||||
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
trigger,
|
||||
@@ -602,6 +640,7 @@ export class RatesService {
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
@@ -614,6 +653,7 @@ export class RatesService {
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateType,
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
@@ -791,6 +831,17 @@ export class RatesService {
|
||||
updates.originYardId = yardScope.originYardId;
|
||||
updates.destinationYardId = yardScope.destinationYardId;
|
||||
|
||||
// The owning line is re-validated on every edit: a patch that flips the
|
||||
// direction to EXPORT has to be refused for a line rate, and a patch that
|
||||
// moves the rate to a suspended line too.
|
||||
const shippingLineCompanyId = await this.resolveShippingLineScope(
|
||||
dto.shippingLineCompanyId !== undefined
|
||||
? dto.shippingLineCompanyId
|
||||
: existing.shippingLineCompanyId,
|
||||
updates.tradeDirection,
|
||||
);
|
||||
updates.shippingLineCompanyId = shippingLineCompanyId;
|
||||
|
||||
// Keep the derived rateType in sync with whatever changed.
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
@@ -839,6 +890,7 @@ export class RatesService {
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/** Payload for a shipping line cancelling its own booking. */
|
||||
export class CancelShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Why the booking is being cancelled. Recorded on the booking's review-note log.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
|
||||
|
||||
/**
|
||||
* One physical container on a line — number, seal, VGM and its per-container
|
||||
* handling switches. Same shape the customer shipment form submits.
|
||||
*/
|
||||
export class CompleteShippingLineContainerUnitDto {
|
||||
@ApiProperty({ example: "MSCU1234567" })
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmTons!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One container line the shipping line ships — container type + count, the
|
||||
* same shape the customer one-time form collects. When `units` is sent (the
|
||||
* full booking page), per-container numbers/seals/VGM and handling switches
|
||||
* are persisted exactly like the customer shipment form; without it (legacy
|
||||
* modal shape) the line-level counts stand alone.
|
||||
*/
|
||||
export class CompleteShippingLineContainerLineDto {
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerSize?: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [CompleteShippingLineContainerUnitDto],
|
||||
description:
|
||||
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompleteShippingLineContainerUnitDto)
|
||||
units?: CompleteShippingLineContainerUnitDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion payload for a shipping-line booking whose documents Operations
|
||||
* has approved (CLEARANCE_READY): the cargo and the binding shipment day —
|
||||
* the two things `initiate` deliberately left empty.
|
||||
*/
|
||||
export class CompleteShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
description: "Binding shipment day (train departure day).",
|
||||
example: "2026-09-01",
|
||||
})
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [CompleteShippingLineContainerLineDto],
|
||||
description: "Container freight: what ships. Required for CONTAINER bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompleteShippingLineContainerLineDto)
|
||||
containers?: CompleteShippingLineContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description: "Bulk freight: the cargo type. Required for BULK bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: total weight in tons. Required for BULK bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: hazardous portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: refrigerated portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "What the containers carry." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
export class CreateShippingLineDto {
|
||||
@ApiProperty({ example: "Ethiopian Shipping Lines" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/**
|
||||
* Becomes the IAM account's email — the activation link is sent here, so it
|
||||
* is required even though the customer equivalent is optional.
|
||||
*/
|
||||
@ApiProperty({ example: "ops@esl.com.et" })
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "+251911223344" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "ESLK",
|
||||
description: "Standard Carrier Alpha Code — 2-4 letters",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z]{2,4}$/, {
|
||||
message: "SCAC must be 2-4 letters",
|
||||
})
|
||||
scacCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "IMO9074729" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
imoNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "ESLU" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bicCode?: string;
|
||||
|
||||
/**
|
||||
* Login name. Optional — defaults to the email, which is what the line will
|
||||
* naturally try first.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: "esl-ops" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity";
|
||||
|
||||
/**
|
||||
* Payload for initiating a bare shipping-line booking.
|
||||
*
|
||||
* A customer's bare booking inherits its lane from the contract it is initiated
|
||||
* under. Shipping lines have no contract, so the lane comes from a route the
|
||||
* caller picks — one choice that yields origin, destination and trade direction
|
||||
* together, rather than three fields that can contradict each other.
|
||||
*/
|
||||
export class InitiateShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.",
|
||||
})
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: "Service type being booked.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: FREIGHT_TYPES,
|
||||
description: "Freight type. Defaults to CONTAINER.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(FREIGHT_TYPES)
|
||||
freightType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
/** Finance's request to bill a batch of unbilled credits as one invoice. */
|
||||
export class GenerateCreditInvoiceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The unbilled credits to bill. All must belong to the same shipping line and share one currency.",
|
||||
type: [String],
|
||||
format: "uuid",
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID("4", { each: true })
|
||||
creditIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Pay window in days from issue. Defaults to the standard invoice term.",
|
||||
minimum: 1,
|
||||
example: 14,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/** Finance's request for a manual action on a credit invoice (maker step). */
|
||||
export class RequestInvoiceActionDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the action is needed. Shown to the approver and kept for audit.",
|
||||
example: "Paid by bank transfer, slip #TT-4491",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
|
||||
example: "TT-4491",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
paymentReference?: string;
|
||||
}
|
||||
|
||||
/** The decision on a pending request (approve and reject routes). */
|
||||
export class DecideInvoiceActionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Decision note. Required when rejecting.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Write-off of a single unbilled credit. */
|
||||
export class CancelCreditDto {
|
||||
@ApiProperty({
|
||||
description: "Why the credit is being written off. Recorded on the row.",
|
||||
example: "Booking voided before departure",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
ShippingLineCompany,
|
||||
ShippingLineStatus,
|
||||
} from "../entities/shipping-line-company.entity";
|
||||
|
||||
export class ShippingLineResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
imoNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
bicCode?: string | null;
|
||||
|
||||
@ApiProperty({ enum: ShippingLineStatus })
|
||||
status: ShippingLineStatus;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.imoNumber = entity.imoNumber ?? null;
|
||||
this.bicCode = entity.bicCode ?? null;
|
||||
this.status = entity.status;
|
||||
this.createdAt = entity.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class RegisterShippingLineResponseDto {
|
||||
@ApiProperty({ type: ShippingLineResponseDto })
|
||||
shippingLine: ShippingLineResponseDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Masked destination the activation link was sent to, or null if delivery failed.",
|
||||
example: "o**@esl.com.et",
|
||||
})
|
||||
activationSentTo: string | null;
|
||||
|
||||
constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) {
|
||||
this.shippingLine = new ShippingLineResponseDto(shippingLine);
|
||||
this.activationSentTo = activationSentTo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
export enum ShippingLineStatus {
|
||||
Active = "active",
|
||||
Suspended = "suspended",
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping line — a carrier that books rail capacity directly, registered by
|
||||
* backoffice staff rather than self-signing up.
|
||||
*
|
||||
* Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping
|
||||
* line carries none of what `companies` exists to hold — no TIN, no business
|
||||
* licence, no eTrade authenticity lookup, no operational `company_profiles`, no
|
||||
* onboarding wizard state. Modelling it there would mean making all of that
|
||||
* nullable for one row shape that never uses it.
|
||||
*
|
||||
* The company IS the account: there is no contact-person row (customers get one
|
||||
* via `external_profiles`), so `user_id` lives here and the login credentials
|
||||
* are the company's own. That is also why the password-reset flow resolves a
|
||||
* shipping line straight off this table instead of through a primary contact.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_companies" })
|
||||
@Index(["status"])
|
||||
export class ShippingLineCompany extends BaseEntity {
|
||||
/**
|
||||
* The IAM account (`iam.users`, userType `individual`) that signs in as this
|
||||
* shipping line. No FK: `iam` is a separate schema owned by the IAM service,
|
||||
* and the rest of the codebase reaches it by query rather than by relation.
|
||||
*/
|
||||
@Column({ name: "user_id", type: "uuid", unique: true })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
|
||||
/** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */
|
||||
@Column({ name: "scac_code", type: "varchar", length: 4, nullable: true })
|
||||
scacCode?: string | null;
|
||||
|
||||
/** IMO number of the vessel operator. */
|
||||
@Column({ name: "imo_number", type: "varchar", length: 20, nullable: true })
|
||||
imoNumber?: string | null;
|
||||
|
||||
/** BIC code — the container prefix the line's equipment is registered under. */
|
||||
@Column({ name: "bic_code", type: "varchar", length: 20, nullable: true })
|
||||
bicCode?: string | null;
|
||||
|
||||
/** Mirrors the IAM account's email; the activation link is sent here. */
|
||||
@Column({ name: "email", type: "varchar", length: 150 })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineStatus,
|
||||
default: ShippingLineStatus.Active,
|
||||
})
|
||||
status!: ShippingLineStatus;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
import { ShippingLineCompany } from "./shipping-line-company.entity";
|
||||
|
||||
/** Where a credit sits between "service used" and "money received". */
|
||||
export enum ShippingLineCreditStatus {
|
||||
/** Service used, priced, not yet on any invoice. Counts as debt. */
|
||||
Unbilled = "UNBILLED",
|
||||
/** Finance put it on an invoice; awaiting payment. Still counts as debt. */
|
||||
Billed = "BILLED",
|
||||
/** The invoice settled. Terminal — no longer debt, and never re-billed. */
|
||||
Paid = "PAID",
|
||||
/** Written off / booking voided. Terminal, excluded from every total. */
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Statuses a shipping line still owes money for. */
|
||||
export const OUTSTANDING_CREDIT_STATUSES = [
|
||||
ShippingLineCreditStatus.Unbilled,
|
||||
ShippingLineCreditStatus.Billed,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* What a shipping line owes for one booking.
|
||||
*
|
||||
* Shipping lines get the service first and pay later, so a booking of theirs
|
||||
* raises no invoice and passes no payment gate — it raises one of these. The
|
||||
* amount is frozen when the booking is priced and is never recalculated, so a
|
||||
* later rate change cannot silently alter a debt already incurred.
|
||||
*
|
||||
* Finance batches unbilled credits into one invoice (see
|
||||
* `ShippingLineCreditsService.generateInvoice`); the line pays that invoice
|
||||
* through the ordinary CBE flow; settlement flips the batch to PAID and the
|
||||
* debt disappears. The outstanding figure is always derived by summing
|
||||
* {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column,
|
||||
* because a stored balance is one missed UPDATE away from being a lie.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_credits" })
|
||||
@Index(["shippingLineCompanyId", "status"])
|
||||
@Index(["invoiceId"])
|
||||
export class ShippingLineCredit extends BaseEntity {
|
||||
/** The line that owes this. */
|
||||
@Column({ name: "shipping_line_company_id", type: "uuid" })
|
||||
shippingLineCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany)
|
||||
@JoinColumn({ name: "shipping_line_company_id" })
|
||||
shippingLineCompany?: ShippingLineCompany;
|
||||
|
||||
/**
|
||||
* The booking that incurred the charge. Unique among live rows (partial
|
||||
* index excludes soft-deleted and CANCELLED), so one booking can never be
|
||||
* billed twice.
|
||||
*/
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** Frozen at pricing time. Never recalculated. */
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineCreditStatus,
|
||||
default: ShippingLineCreditStatus.Unbilled,
|
||||
})
|
||||
status!: ShippingLineCreditStatus;
|
||||
|
||||
/** What the charge is for; becomes the invoice line description. */
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** The invoice this credit was billed on; null while UNBILLED. */
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
/** When finance put it on an invoice. */
|
||||
@Column({ name: "billed_at", type: "timestamptz", nullable: true })
|
||||
billedAt?: Date | null;
|
||||
|
||||
/** When that invoice settled. */
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: "cancelled_at", type: "timestamptz", nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "cancellation_reason",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
cancellationReason?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
|
||||
/** What finance asked to do to a shipping-line credit invoice. */
|
||||
export enum ShippingLineInvoiceActionType {
|
||||
/** Record a full offline settlement (paid outside the gateway). */
|
||||
MarkPaid = "MARK_PAID",
|
||||
/** Void the invoice; its credits return to the unbilled pool. */
|
||||
Cancel = "CANCEL",
|
||||
}
|
||||
|
||||
export enum ShippingLineInvoiceActionStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* Marking an invoice paid by hand erases real debt, and cancelling one
|
||||
* releases its credits back to the unbilled pool — either done unilaterally is
|
||||
* a one-person fraud path. So finance REQUESTS the action (one permission)
|
||||
* and a chief APPROVES or REJECTS it (a separate permission, different
|
||||
* person). Every request is kept, decided or not: the table is the audit
|
||||
* trail of who asked, who decided, and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
|
||||
@Index(["invoiceId", "status"])
|
||||
export class ShippingLineInvoiceApproval extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid" })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
|
||||
action!: ShippingLineInvoiceActionType;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineInvoiceActionStatus,
|
||||
default: ShippingLineInvoiceActionStatus.Pending,
|
||||
})
|
||||
status!: ShippingLineInvoiceActionStatus;
|
||||
|
||||
/** IAM user id of the finance staff who raised the request. */
|
||||
@Column({ name: "requested_by", type: "uuid" })
|
||||
requestedBy!: string;
|
||||
|
||||
/** Why the action is needed; shown to the approver, kept for audit. */
|
||||
@Column({ name: "reason", type: "varchar", length: 500 })
|
||||
reason!: string;
|
||||
|
||||
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
|
||||
@Column({
|
||||
name: "payment_reference",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
paymentReference?: string | null;
|
||||
|
||||
/** IAM user id of the chief who approved/rejected; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
|
||||
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion half of shipping-line bookings, sharing the
|
||||
* `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}.
|
||||
* Separate controller because it lives in its own module — see
|
||||
* {@link ShippingLineBookingCompletionService} for why the module split exists.
|
||||
*/
|
||||
@ApiTags("shipping-line-bookings")
|
||||
@Controller("shipping-line-bookings")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineBookingCompletionController {
|
||||
constructor(
|
||||
private readonly completionService: ShippingLineBookingCompletionService,
|
||||
) {}
|
||||
|
||||
@Get(":id/available-days")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
|
||||
})
|
||||
async availableDays(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.completionService.availableDaysMine(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/trains")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
|
||||
})
|
||||
async trainsForDay(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("date") date?: string,
|
||||
@Query("sizes") sizes?: string,
|
||||
@Query("cargoTypeId") cargoTypeId?: string,
|
||||
@Query("wagons") wagons?: string,
|
||||
) {
|
||||
return this.completionService.trainsForDayMine(user.id, id, date, {
|
||||
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
|
||||
cargoTypeId: cargoTypeId || undefined,
|
||||
wagons: wagons ? Number(wagons) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":id/price-preview")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
|
||||
})
|
||||
async pricePreview(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
return this.completionService.previewPriceMine(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/operations")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
|
||||
})
|
||||
async operations(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.completionService.operationsMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
|
||||
})
|
||||
async completeMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
return this.completionService.completeMine(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BookingsModule } from "../bookings/bookings.module";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller";
|
||||
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
|
||||
import { ShippingLineCompaniesModule } from "./shipping-line-companies.module";
|
||||
|
||||
/**
|
||||
* Deliberately a LEAF module — registered in AppModule and imported by
|
||||
* nothing. Completion needs BookingsModule (pricing + the operation-request
|
||||
* transition), but ShippingLineCompaniesModule sits under rule-engine and
|
||||
* companies, which sit under BookingsModule; importing bookings from there
|
||||
* closes a module cycle Nest cannot construct. Keeping the completion flow
|
||||
* here keeps the graph acyclic with no forwardRef chains.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Booking]),
|
||||
BookingsModule,
|
||||
TrainSchedulingModule,
|
||||
ShippingLineCompaniesModule,
|
||||
],
|
||||
controllers: [ShippingLineBookingCompletionController],
|
||||
providers: [ShippingLineBookingCompletionService],
|
||||
})
|
||||
export class ShippingLineBookingCompletionModule {}
|
||||
@@ -0,0 +1,766 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
||||
import { BookingsService } from "../bookings/bookings.service";
|
||||
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
||||
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import {
|
||||
BookingBatchService,
|
||||
type TrainOptionCargoOverrides,
|
||||
} from "../train-scheduling/booking-batch.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
||||
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
|
||||
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
/**
|
||||
* Completion of a shipping-line booking — the step after Operations approves
|
||||
* its documents, mirroring what a customer does at that point: cargo + binding
|
||||
* shipment day go in, the booking prices off the line's negotiated rates and
|
||||
* the request lands with Operations.
|
||||
*
|
||||
* Its own module (not part of {@link ShippingLineBookingsService}) because it
|
||||
* needs BookingsModule (pricing, the operation-request transition) and
|
||||
* TrainSchedulingModule — and ShippingLineCompaniesModule is imported by
|
||||
* rule-engine/companies, which sit UNDER BookingsModule. Importing bookings
|
||||
* from there closes a module cycle Nest cannot construct; a leaf module that
|
||||
* nothing imports keeps the graph acyclic.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineBookingCompletionService {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly creditsService: ShippingLineCreditsService,
|
||||
) {}
|
||||
|
||||
/** Same session→owner resolution every shipping-line entry point uses. */
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
await this.shippingLineCompaniesService.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
if (shippingLine.status !== "active") {
|
||||
throw new ForbiddenException(
|
||||
"This shipping-line account is suspended and cannot create bookings.",
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
private async requireOwnBooking(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
relations?: { bookingContainers?: boolean },
|
||||
) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
relations,
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED,
|
||||
* soonest first). These trains run NO booking-window cycle — the line books
|
||||
* whenever it wants until the close offset stamped in `windowClosesAt` — and
|
||||
* they are excluded from every customer pool, so this is the only source
|
||||
* that can offer them.
|
||||
*/
|
||||
private async dedicatedTrainsForBooking(booking: Booking) {
|
||||
if (!booking.shippingLineCompanyId) return [];
|
||||
return this.bookingsRepository.manager.getRepository(TrainSchedule).find({
|
||||
where: {
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId ?? undefined,
|
||||
destinationStationId: booking.destinationYardId ?? undefined,
|
||||
status: In(["DRAFT", "SCHEDULED"]),
|
||||
},
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Still bookable: the close offset before departure has not passed yet. */
|
||||
private isStillOpen(schedule: TrainSchedule): boolean {
|
||||
const closesAt =
|
||||
schedule.windowClosesAt ?? schedule.scheduledDepartureDate;
|
||||
return closesAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's dedicated trains on the booking's lane for one shipment day,
|
||||
* each with per-wagon-type free space — the completion form's train picker.
|
||||
* A booking rides ONE schedule, so with several departures that day the
|
||||
* line picks which; the pick is validated again at complete time.
|
||||
*/
|
||||
async trainsForDayMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
date: string | undefined,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
if (!booking.shippingLineCompanyId) return [];
|
||||
return this.bookingBatchService.dedicatedTrainOptionsForDay(
|
||||
booking,
|
||||
date ? eatDay(new Date(date)) : null,
|
||||
booking.shippingLineCompanyId,
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the shipping line may pick as the shipment day.
|
||||
*
|
||||
* Lanes with trains DEDICATED to this line offer exactly those trains' days,
|
||||
* open until each train's close offset — no window cycle. Lanes without a
|
||||
* dedicated train fall back to the shared customer day pool, exactly as
|
||||
* before. Ownership is checked first so one line cannot probe another's
|
||||
* booking.
|
||||
*/
|
||||
async availableDaysMine(userId: string, bookingId: string) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
||||
if (dedicated.length === 0) {
|
||||
return this.bookingsService.availableDaysForBooking(bookingId);
|
||||
}
|
||||
const days = [
|
||||
...new Set(
|
||||
dedicated
|
||||
.filter((s) => this.isStillOpen(s))
|
||||
.map((s) => eatDay(s.scheduledDepartureDate)),
|
||||
),
|
||||
];
|
||||
return { days };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a bare shipping-line booking once Operations has approved its
|
||||
* documents (CLEARANCE_READY), or after Operations returned the request
|
||||
* (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go
|
||||
* in, the booking is priced off the line's negotiated rates, and the request
|
||||
* lands with Operations (OPERATION_REQUEST_PENDING) through the same
|
||||
* transition customers use.
|
||||
*
|
||||
* Payment differs from customers by design: no invoice is issued here.
|
||||
* Shipping lines run on the credit ledger — the priced amount is recorded as
|
||||
* an UNBILLED credit and Finance bills a batch later, so the booking
|
||||
* proceeds without a payment gate.
|
||||
*/
|
||||
async completeMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId, {
|
||||
bookingContainers: true,
|
||||
});
|
||||
if (
|
||||
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Your documents must be approved before the booking can be completed.",
|
||||
);
|
||||
}
|
||||
|
||||
// Completion is booking time. A lane with trains DEDICATED to this line
|
||||
// has no window concept at all: the line books whenever it wants until the
|
||||
// train's close offset. Only a lane with no dedicated train falls back to
|
||||
// the customer window gate, unchanged.
|
||||
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
||||
const pickedDay = eatDay(new Date(dto.scheduledDate));
|
||||
const dedicatedOnDay = dedicated.filter(
|
||||
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
|
||||
);
|
||||
let bypassDayPool = false;
|
||||
let requestedTrainScheduleId: string | null = null;
|
||||
if (dedicatedOnDay.length > 0) {
|
||||
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
|
||||
if (openOnDay.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Booking for your train on this day has closed — the cut-off before departure has passed.",
|
||||
);
|
||||
}
|
||||
// A booking rides ONE schedule. Several departures that day → the line
|
||||
// must say which; a single one is picked implicitly. The id comes from
|
||||
// the request, so it is validated against the day's own trains.
|
||||
if (dto.trainScheduleId) {
|
||||
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
|
||||
if (!picked) {
|
||||
throw new BadRequestException(
|
||||
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
|
||||
);
|
||||
}
|
||||
requestedTrainScheduleId = picked.id;
|
||||
} else if (openOnDay.length === 1) {
|
||||
requestedTrainScheduleId = openOnDay[0].id;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"More than one of your trains departs that day — select which train this booking rides.",
|
||||
);
|
||||
}
|
||||
// The day is backed by the line's own train, which every customer pool
|
||||
// deliberately excludes — so the day-pool gate downstream must not run.
|
||||
bypassDayPool = true;
|
||||
} else if (dedicated.length > 0) {
|
||||
throw new BadRequestException(
|
||||
"Pick one of your assigned train days for this route.",
|
||||
);
|
||||
} else {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: booking.originYardId ?? null,
|
||||
destinationYardId: booking.destinationYardId ?? null,
|
||||
scheduledDate: dto.scheduledDate,
|
||||
direction: booking.tradeDirection ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
const restatesCargo = Boolean(
|
||||
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
|
||||
);
|
||||
|
||||
// Operations may return the request asking for the CARGO to change, not
|
||||
// just the day. A resubmit that restates cargo starts completion over:
|
||||
// the recorded (unbilled) credit is written off and the persisted cargo
|
||||
// wiped, so the fresh path below re-persists, re-prices and re-records.
|
||||
// Once the credit is on an issued invoice the cargo is frozen — the
|
||||
// invoice total must keep matching what it bills.
|
||||
if (hasCargo && restatesCargo) {
|
||||
const credit = await this.bookingsRepository.manager
|
||||
.getRepository(ShippingLineCredit)
|
||||
.findOne({ where: { bookingId } });
|
||||
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
|
||||
await this.creditsService.cancelCredit(
|
||||
credit.id,
|
||||
"Cargo changed before billing — booking re-priced on completion.",
|
||||
);
|
||||
} else if (
|
||||
credit &&
|
||||
credit.status !== ShippingLineCreditStatus.Cancelled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
|
||||
);
|
||||
}
|
||||
await this.wipeCargo(bookingId);
|
||||
hasCargo = false;
|
||||
}
|
||||
|
||||
// First completion persists cargo and prices the booking; a day-only
|
||||
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
|
||||
// operation request with the cargo (and price) it already carries.
|
||||
if (!hasCargo) {
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
await this.persistContainerLines(booking, dto);
|
||||
} else {
|
||||
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Bulk bookings need a cargo type and a total weight in tons.",
|
||||
);
|
||||
}
|
||||
const cargoType = await this.bookingsRepository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(
|
||||
`Cargo type ${dto.cargoTypeId} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
cargoTypeId:
|
||||
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm:
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
|
||||
bulkTotalWeightTons:
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
|
||||
// Bulk handling portions — sized against the cargo, billed by pricing.
|
||||
...(booking.freightType === "BULK"
|
||||
? {
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
}
|
||||
: {}),
|
||||
// Hazard is per-line for containers; the booking-level flag is what
|
||||
// pricing bills the surcharge from.
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Same for reefer: the rule engine's REEFER trigger fires on the
|
||||
// booking-level flag (or a reefer container TYPE) — a ticked reefer
|
||||
// switch on a standard box only sets the per-line count, so without
|
||||
// this flag the surcharge silently never bills.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
// Shipping lines are always billed in ETB: the charge lands on the
|
||||
// ETB credit ledger, so the currency is enforced here rather than
|
||||
// trusted from the payload.
|
||||
paymentCurrency: "ETB",
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: true, serviceType: true },
|
||||
});
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(
|
||||
loaded ?? booking,
|
||||
);
|
||||
// A zero price or hard block means no rate is configured for this line
|
||||
// on this lane. Roll the cargo back so the booking stays completable —
|
||||
// the approved clearance is not lost — and surface why.
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
await this.wipeCargo(bookingId);
|
||||
throw new BadRequestException(
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join("; ")
|
||||
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
// No credit is recorded here: completion only REQUESTS the operation.
|
||||
// The charge lands on the line's ledger when Operations accepts —
|
||||
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
|
||||
// request that is returned or never accepted creates no debt.
|
||||
}
|
||||
|
||||
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
|
||||
// machine a customer booking uses. When the day is backed by a dedicated
|
||||
// train, the customer day-pool gate is skipped (validated above instead).
|
||||
return this.bookingTransitionService.requestOperation(
|
||||
bookingId,
|
||||
dto.scheduledDate,
|
||||
requestedTrainScheduleId,
|
||||
bypassDayPool ? { bypassDayPool: true } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative price preview for the completion form's confirm step: the
|
||||
* SAME compute the completion itself runs, over an in-memory probe shaped
|
||||
* exactly like completeMine would persist the booking — so the figure the
|
||||
* shipping line confirms is line-for-line what it will owe.
|
||||
*
|
||||
* The result is not advisory-only: the breakdown is saved on the booking and
|
||||
* the rate snapshots are (re)written, so every re-preview refreshes them.
|
||||
* Nothing else is persisted — no cargo rows, no credit, no transition.
|
||||
*/
|
||||
async previewPriceMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId, {
|
||||
bookingContainers: true,
|
||||
});
|
||||
if (
|
||||
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Your documents must be approved before the booking can be priced.",
|
||||
);
|
||||
}
|
||||
|
||||
// In-memory cargo, mirroring what completeMine persists.
|
||||
let probeContainers: Partial<BookingContainer>[] = [];
|
||||
let bulkFields: Record<string, unknown> = {};
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException(
|
||||
"At least one container line is required.",
|
||||
);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
const figures = this.lineFigures(line);
|
||||
probeContainers.push({
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: containerType.sizeFt
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Bulk bookings need a cargo type and a total weight in tons.",
|
||||
);
|
||||
}
|
||||
const cargoType = await this.bookingsRepository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
bulkFields = {
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
|
||||
bulkTotalWeightTons: Number(dto.cargoWeightTons),
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
};
|
||||
probeContainers = [];
|
||||
}
|
||||
|
||||
// Prototype-preserving clone so entity getters keep working — the same
|
||||
// probe trick the contract preview uses.
|
||||
const probe = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(booking)),
|
||||
booking,
|
||||
{
|
||||
bookingContainers: probeContainers,
|
||||
paymentCurrency: "ETB",
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Mirrors completeMine: without the booking-level flag the engine's
|
||||
// REEFER trigger never fires for reefer opt-ins on standard boxes,
|
||||
// and the quote would show base freight only.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
...bulkFields,
|
||||
},
|
||||
) as Booking;
|
||||
|
||||
const computed =
|
||||
await this.bookingPricingService.computePriceForBooking(probe);
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join("; ")
|
||||
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the quoted figure: breakdown on the booking, snapshots of the
|
||||
// rates it was built from. createPricingSnapshots clears the previous
|
||||
// artifacts first, so a re-preview replaces the old quote rather than
|
||||
// stacking a second one.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What operations has done with the booking so far: the train it rides
|
||||
* (assigned, or the requested one before assignment) and the wagons the
|
||||
* batch engine allocated to it, with any container numbers loaded per wagon.
|
||||
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
|
||||
*/
|
||||
async operationsMine(userId: string, bookingId: string) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
const manager = this.bookingsRepository.manager;
|
||||
|
||||
const scheduleId =
|
||||
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
|
||||
let train: Record<string, unknown> | null = null;
|
||||
if (scheduleId) {
|
||||
const schedule = await manager.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
});
|
||||
if (schedule) {
|
||||
train = {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference,
|
||||
trainNumber: schedule.trainNumber,
|
||||
status: schedule.status,
|
||||
direction: schedule.direction,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
originLabel:
|
||||
schedule.originStation?.label ??
|
||||
schedule.originStation?.code ??
|
||||
"Origin",
|
||||
destinationLabel:
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
"Destination",
|
||||
// Whether this is the confirmed assignment or still the request.
|
||||
assigned: Boolean(booking.trainScheduleId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allocations = await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.find({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
trainSetWagon: { wagonType: true, physicalWagon: true },
|
||||
containerItems: true,
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
|
||||
const wagons = allocations.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
status: allocation.status,
|
||||
loadType: allocation.loadType,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons),
|
||||
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
|
||||
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType:
|
||||
allocation.trainSetWagon?.wagonType?.name ??
|
||||
allocation.trainSetWagon?.wagonType?.code ??
|
||||
null,
|
||||
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
}));
|
||||
|
||||
return { train, wagons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a line's container type: by id when the payload carries one, else
|
||||
* from the size string ("40ft" → the active 40ft type, preferring the reefer
|
||||
* variant when the line ships reefer boxes). Resolution lives HERE, not in
|
||||
* the portal, so a slow or failed catalog fetch can never block a booking
|
||||
* with a phantom "type not configured" error — mirrors the customer flow's
|
||||
* server-side size→type mapping.
|
||||
*/
|
||||
private async resolveContainerType(line: {
|
||||
containerTypeId?: string;
|
||||
containerSize?: string;
|
||||
reeferQuantity?: number;
|
||||
units?: { isReefer?: boolean }[];
|
||||
}): Promise<ContainerType> {
|
||||
const containerTypeRepo =
|
||||
this.bookingsRepository.manager.getRepository(ContainerType);
|
||||
|
||||
if (line.containerTypeId) {
|
||||
const byId = await containerTypeRepo.findOne({
|
||||
where: { id: line.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!byId) {
|
||||
throw new NotFoundException(
|
||||
`Container type ${line.containerTypeId} not found`,
|
||||
);
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
const sizeFt = parseInt(line.containerSize ?? "", 10);
|
||||
if (!Number.isFinite(sizeFt)) {
|
||||
throw new BadRequestException(
|
||||
"Each container line needs a containerTypeId or a containerSize.",
|
||||
);
|
||||
}
|
||||
const candidates = await containerTypeRepo.find({
|
||||
where: { isActive: true },
|
||||
});
|
||||
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
|
||||
if (!ofSize.length) {
|
||||
throw new BadRequestException(
|
||||
`No ${sizeFt}ft container type is configured — please contact Operations.`,
|
||||
);
|
||||
}
|
||||
const wantsReefer =
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer);
|
||||
if (wantsReefer) {
|
||||
const reefer = ofSize.find((ct) => ct.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A line's derived figures. With per-container rows (the full booking page),
|
||||
* counts and VGM come FROM the rows — each container's switches are the
|
||||
* source of truth. Without them, the line-level figures stand alone.
|
||||
*/
|
||||
private lineFigures(line: {
|
||||
quantity: number;
|
||||
vgmPerUnitTons?: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
|
||||
}) {
|
||||
const units = line.units ?? [];
|
||||
const hazardous = units.length
|
||||
? units.filter((u) => u.isHazardous).length
|
||||
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
|
||||
const reefer = units.length
|
||||
? units.filter((u) => u.isReefer).length
|
||||
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
|
||||
const totalVgm = units.length
|
||||
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
|
||||
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
|
||||
const vgmPerUnit = units.length
|
||||
? totalVgm / units.length
|
||||
: Number(line.vgmPerUnitTons ?? 0);
|
||||
return { hazardous, reefer, totalVgm, vgmPerUnit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the container lines of a CONTAINER completion. Same row shape the
|
||||
* customer paths write (quantity per type, VGM totals, wagon share) — the
|
||||
* per-unit ISO numbers customers also skip at booking time arrive later at
|
||||
* yard operations.
|
||||
*/
|
||||
private async persistContainerLines(
|
||||
booking: Booking,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
): Promise<void> {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException("At least one container line is required.");
|
||||
}
|
||||
|
||||
const containerRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainer);
|
||||
const unitRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
// Counts and VGM derived by lineFigures — the same math the price
|
||||
// preview runs, so the persisted cargo always matches the quote.
|
||||
const units = line.units ?? [];
|
||||
const figures = this.lineFigures(line);
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: containerType.sizeFt
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
}),
|
||||
);
|
||||
let sortOrder = 0;
|
||||
for (const unit of units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber.trim().toUpperCase(),
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: Number(unit.vgmTons ?? 0),
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn: false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Roll a failed/superseded completion back to the bare-booking shape. */
|
||||
private async wipeCargo(bookingId: string): Promise<void> {
|
||||
await this.bookingsRepository.manager
|
||||
.getRepository(BookingContainer)
|
||||
.softDelete({ bookingId });
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 0,
|
||||
bulkTotalWeightTons: null,
|
||||
totalAmount: 0,
|
||||
pricingBreakdown: null,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings a shipping line makes for itself, from the portal.
|
||||
*
|
||||
* Separate from `/bookings` (customers) on purpose — see
|
||||
* {@link ShippingLineBookingsService} for why the two flows are not merged.
|
||||
* `PortalCustomer` only proves a valid portal session; the service resolves the
|
||||
* shipping-line account from that session and rejects anyone else, so the owner
|
||||
* is never taken from the request body.
|
||||
*/
|
||||
@ApiTags("shipping-line-bookings")
|
||||
@Controller("shipping-line-bookings")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineBookingsController {
|
||||
constructor(
|
||||
private readonly shippingLineBookingsService: ShippingLineBookingsService,
|
||||
) {}
|
||||
|
||||
@Post("initiate")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.",
|
||||
})
|
||||
async initiate(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: InitiateShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.initiate(user.id, dto);
|
||||
}
|
||||
|
||||
// Declared before @Get(":id") so the path isn't captured as a booking id.
|
||||
@Get("reference-data")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.",
|
||||
})
|
||||
async referenceData(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.referenceData(user.id);
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "List the signed-in shipping line's bookings." })
|
||||
async listMine(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.listMine(user.id);
|
||||
}
|
||||
|
||||
// Declared before @Get(":id") so the path isn't captured as a booking id.
|
||||
@Get("my-trains")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.",
|
||||
})
|
||||
async listMyTrains(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.listMyTrains(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." })
|
||||
async findMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.shippingLineBookingsService.findMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.",
|
||||
})
|
||||
async cancelMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.cancelMine(
|
||||
user.id,
|
||||
id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { insertWithGeneratedReference } from "@edr/api-common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, MoreThanOrEqual, Repository } from "typeorm";
|
||||
|
||||
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
|
||||
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { ServiceType } from "../rule-engine/entities/service-type.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* The only trade direction a shipping line books.
|
||||
*
|
||||
* Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is
|
||||
* IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT,
|
||||
* same country = DOMESTIC). Export and intercity lanes are therefore neither
|
||||
* offered nor accepted.
|
||||
*/
|
||||
const SHIPPING_LINE_DIRECTION = "IMPORT";
|
||||
|
||||
/**
|
||||
* Statuses a shipping line may cancel its own booking from — everything before
|
||||
* the booking is priced. Past this point cancelling has billing consequences
|
||||
* (fees, credit notes) and belongs with Operations.
|
||||
*/
|
||||
const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"CHANGES_REQUESTED",
|
||||
];
|
||||
|
||||
/**
|
||||
* Booking creation for shipping lines.
|
||||
*
|
||||
* Deliberately separate from `BookingsService` / `ContractBookingService`
|
||||
* rather than a branch inside them. Those are built end to end around a
|
||||
* customer: a `companies` row, an approved operational `company_profile`, a
|
||||
* contract supplying route/quantities, and contract-capacity accounting. A
|
||||
* shipping line has none of that — it books directly, without a contract — so
|
||||
* branching there would mean threading "no company, no profile, no contract"
|
||||
* through every method a customer booking passes through. Keeping it here means
|
||||
* the customer paths are not touched at all.
|
||||
*
|
||||
* What IS shared is the table and the downstream lifecycle: the row lands in
|
||||
* `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its
|
||||
* documents against the `shipping_line_booking_documents` file-upload setting,
|
||||
* and Operations reviews and finalizes them through the same clearance flow
|
||||
* customers already use.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineBookingsService {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the shipping-line account for a signed-in user, or reject. Every
|
||||
* entry point goes through this: the owner is taken from the session, never
|
||||
* from the request body, so one shipping line cannot book as another.
|
||||
*/
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
await this.shippingLineCompaniesService.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
if (shippingLine.status !== "active") {
|
||||
throw new ForbiddenException(
|
||||
"This shipping-line account is suspended and cannot create bookings.",
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog the initiate form needs: the lanes EDR actually runs, and the
|
||||
* services that can be booked on their own.
|
||||
*
|
||||
* Routes are offered instead of two loose yard pickers so a shipping line
|
||||
* cannot invent a lane that does not exist — and because the route already
|
||||
* carries its trade direction, which is otherwise guesswork.
|
||||
*
|
||||
* Read-only and scoped to bookable rows, which is why it lives here rather
|
||||
* than reusing the staff `/routes` controller (gated behind fleet
|
||||
* permissions a shipping line does not and should not hold).
|
||||
*/
|
||||
async referenceData(userId: string) {
|
||||
await this.requireShippingLine(userId);
|
||||
|
||||
const [routes, serviceTypes, containerTypes, cargoTypes] =
|
||||
await Promise.all([
|
||||
this.bookingsRepository.manager.getRepository(Route).find({
|
||||
// Shipping lines only move inbound cargo: it lands at the Djibouti port
|
||||
// and runs inland to Ethiopia. Filtering here rather than in the portal
|
||||
// means an export or intercity lane is never offered AND never
|
||||
// accepted — `initiate` re-checks the same rule below.
|
||||
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
}),
|
||||
// Customs-bundled services are excluded: those run the phased ET/DJ
|
||||
// customs workflow, which is a contract-backed flow a shipping line has
|
||||
// no part in. Their clearance is the single document set Operations
|
||||
// reviews on the booking itself.
|
||||
this.bookingsRepository.manager.getRepository(ServiceType).find({
|
||||
where: {
|
||||
canBeBookedAlone: true,
|
||||
includesCustoms: false,
|
||||
isActive: true,
|
||||
},
|
||||
order: { displayOrder: "ASC" },
|
||||
}),
|
||||
// For the completion form: what ships. Container types for CONTAINER
|
||||
// bookings, cargo types for BULK ones.
|
||||
this.bookingsRepository.manager.getRepository(ContainerType).find({
|
||||
where: { isActive: true },
|
||||
}),
|
||||
this.bookingsRepository.manager.getRepository(CargoType).find({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
routes: routes.map((route) => ({
|
||||
id: route.id,
|
||||
label: formatRouteLabel(route),
|
||||
direction: route.direction,
|
||||
originYardId: route.originYardId,
|
||||
// Per-yard labels so the portal can offer origin and destination as two
|
||||
// separate pickers (the shape the customer form uses) while still
|
||||
// resolving the pair back to one of these routes.
|
||||
originLabel:
|
||||
route.originYard?.label ?? route.originYard?.code ?? "Origin",
|
||||
destinationYardId: route.destinationYardId,
|
||||
destinationLabel:
|
||||
route.destinationYard?.label ??
|
||||
route.destinationYard?.code ??
|
||||
"Destination",
|
||||
})),
|
||||
serviceTypes: serviceTypes.map((service) => ({
|
||||
id: service.id,
|
||||
name: service.serviceName,
|
||||
})),
|
||||
containerTypes: containerTypes.map((ct) => ({
|
||||
id: ct.id,
|
||||
label: ct.label ?? ct.code,
|
||||
sizeFt: ct.sizeFt,
|
||||
isReefer: ct.isReefer,
|
||||
})),
|
||||
// parentGroupId lets the portal tell leaf types from grouping rows.
|
||||
cargoTypes: cargoTypes.map((cargo) => ({
|
||||
id: cargo.id,
|
||||
name: cargo.cargoTypeName,
|
||||
parentGroupId: cargo.parentGroupId ?? null,
|
||||
unitOfMeasure: cargo.unitOfMeasure ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BARE booking for a shipping line — no contract, no cargo, no date
|
||||
* and no price. It exists so documents have something to hang off: the
|
||||
* shipping line uploads them next, Operations approves, and only then is the
|
||||
* booking completed with its cargo and shipment day.
|
||||
*/
|
||||
async initiate(userId: string, dto: InitiateShippingLineBookingDto) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
// The route is the single source of origin, destination AND direction —
|
||||
// resolved server-side so the three can never disagree, and so a caller
|
||||
// cannot post a lane EDR does not run.
|
||||
const route = await this.bookingsRepository.manager
|
||||
.getRepository(Route)
|
||||
.findOne({ where: { id: dto.routeId } });
|
||||
if (!route) {
|
||||
throw new NotFoundException(`Route ${dto.routeId} not found`);
|
||||
}
|
||||
if (route.status !== "AVAILABLE") {
|
||||
throw new BadRequestException(
|
||||
"This route is not currently available for booking.",
|
||||
);
|
||||
}
|
||||
// Enforced here too, not just by filtering the picker: the route id comes
|
||||
// from the request, so an export or intercity lane could otherwise be
|
||||
// posted directly.
|
||||
if (route.direction !== SHIPPING_LINE_DIRECTION) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines can only book inbound (Djibouti to Ethiopia) routes.",
|
||||
);
|
||||
}
|
||||
|
||||
// Only a forward-looking day makes sense; train validation happens later
|
||||
// when Operations schedules it, so only the past is rejected here.
|
||||
let scheduledDate: Date | null = null;
|
||||
if (dto.scheduledDate) {
|
||||
scheduledDate = new Date(dto.scheduledDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (scheduledDate < today) {
|
||||
throw new BadRequestException(
|
||||
"The scheduled date cannot be in the past.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Same reasoning as the picker filter: a customs-bundled service would put
|
||||
// the booking into the phased customs workflow, which has no contract to
|
||||
// hang off here. Checked server-side because the id comes from the request.
|
||||
if (dto.serviceTypeId) {
|
||||
const serviceType = await this.bookingsRepository.manager
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: dto.serviceTypeId } });
|
||||
if (!serviceType) {
|
||||
throw new NotFoundException(
|
||||
`Service type ${dto.serviceTypeId} not found`,
|
||||
);
|
||||
}
|
||||
if (serviceType.includesCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines cannot book a service that bundles customs clearance.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
this.bookingsRepository.save({
|
||||
reference,
|
||||
// The owner columns: a shipping-line booking has no company and no
|
||||
// operational profile, which is exactly what the `chk_bookings_
|
||||
// single_owner` CHECK expects alongside a set shippingLineCompanyId.
|
||||
companyId: null,
|
||||
companyProfileId: null,
|
||||
shippingLineCompanyId: shippingLine.id,
|
||||
status: "AWAITING_DOCUMENTS",
|
||||
bookingType: "ONE_TIME",
|
||||
contractId: null,
|
||||
contractType: "NEW",
|
||||
createdByRole: "SHIPPING_LINE",
|
||||
createdByUserId: userId,
|
||||
// Taken from the chosen route, never from the request body: the
|
||||
// direction is frozen on the route from its yard countries, so
|
||||
// deriving it here keeps it consistent with scheduling and booking
|
||||
// windows, which read the same field.
|
||||
originYardId: route.originYardId,
|
||||
destinationYardId: route.destinationYardId,
|
||||
tradeDirection: route.direction,
|
||||
serviceTypeId: dto.serviceTypeId ?? null,
|
||||
freightType: dto.freightType ?? "CONTAINER",
|
||||
// The shipping line picks its shipment day up front (no later
|
||||
// operation-request step exists for them); cargo is still filled in
|
||||
// when the booking is completed.
|
||||
scheduledDate,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the bookings belonging to the signed-in shipping line, newest first.
|
||||
*
|
||||
* Each row carries `hasQueriedDocuments`: a reviewer querying a document sets
|
||||
* that document's review status but leaves the BOOKING on
|
||||
* DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings
|
||||
* need the shipping line to act. Resolved in one grouped query rather than a
|
||||
* clearance call per row.
|
||||
*/
|
||||
async listMine(userId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const bookings = await this.bookingsRepository.find({
|
||||
where: { shippingLineCompanyId: shippingLine.id },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
if (bookings.length === 0) return [];
|
||||
|
||||
const queried = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
status: "QUERIED",
|
||||
},
|
||||
select: { bookingId: true },
|
||||
});
|
||||
|
||||
const queriedIds = new Set(queried.map((row) => row.bookingId));
|
||||
|
||||
return bookings.map((booking) => ({
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedIds.has(booking.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one of the signed-in shipping line's own bookings. Scoped by owner so
|
||||
* an id belonging to a customer (or another shipping line) reads as missing.
|
||||
*/
|
||||
async findMine(userId: string, bookingId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
// Yards are loaded so the portal can render the lane without a second
|
||||
// lookup — they are set at initiate time from the chosen route. Cargo
|
||||
// (container lines + units, bulk cargo type) rides along for the detail
|
||||
// page's cargo tab once the booking is completed.
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
serviceType: true,
|
||||
cargoType: true,
|
||||
bookingContainers: { containerType: true, units: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// Same flag as the list — see listMine for why booking status alone is not
|
||||
// enough to tell whether the shipping line has something to fix.
|
||||
const queriedCount = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.count({ where: { bookingId, status: "QUERIED" } });
|
||||
|
||||
// The note Operations wrote when returning the request — the line has to
|
||||
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
|
||||
// exposed; the other review-note types are staff-internal.
|
||||
const changeNote =
|
||||
booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||
? await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.findOne({
|
||||
where: { bookingId, type: "CHANGES_REQUESTED" },
|
||||
order: { createdAt: "DESC" },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedCount > 0,
|
||||
operationChangeNote: changeNote?.note ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Train departures dedicated to the signed-in shipping line: schedules whose
|
||||
* `shippingLineCompanyId` is this line's. These trains are hidden from every
|
||||
* customer-facing read, so this endpoint is the ONLY place they surface in
|
||||
* the portal — the home page lists them and the booking detail matches them
|
||||
* to a booking by lane + day.
|
||||
*/
|
||||
async listMyTrains(userId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
// Recent past kept (48h) so a just-departed train is still visible while
|
||||
// its cargo is on the rails; CANCELLED never shows.
|
||||
const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
const schedules = await this.bookingsRepository.manager
|
||||
.getRepository(TrainSchedule)
|
||||
.find({
|
||||
where: {
|
||||
shippingLineCompanyId: shippingLine.id,
|
||||
status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]),
|
||||
scheduledDepartureDate: MoreThanOrEqual(horizon),
|
||||
},
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
reference: s.reference,
|
||||
trainNumber: s.trainNumber,
|
||||
status: s.status,
|
||||
direction: s.direction,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
scheduledArrivalDate: s.scheduledArrivalDate,
|
||||
originYardId: s.originStationId,
|
||||
originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin",
|
||||
destinationYardId: s.destinationStationId,
|
||||
destinationLabel:
|
||||
s.destinationStation?.label ??
|
||||
s.destinationStation?.code ??
|
||||
"Destination",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one of the signed-in shipping line's own bookings.
|
||||
*
|
||||
* Its own method rather than the customer `customerCancel`: that path routes
|
||||
* into `BookingTransitionService.cancel`, whose status whitelist covers the
|
||||
* contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does
|
||||
* not include the document-clearance statuses a shipping-line booking lives
|
||||
* in — so it would reject every one of them.
|
||||
*
|
||||
* Only allowed before the booking is priced and paid. Once it carries a
|
||||
* charge, cancelling is a billing decision (fees, credit notes) that belongs
|
||||
* with Operations, not a self-service button.
|
||||
*/
|
||||
async cancelMine(userId: string, bookingId: string, reason?: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException("This booking is already cancelled.");
|
||||
}
|
||||
if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) {
|
||||
throw new BadRequestException(
|
||||
"This booking can no longer be cancelled — please contact Operations.",
|
||||
);
|
||||
}
|
||||
// Belt and braces: the statuses above are all pre-pricing, so a charge here
|
||||
// would mean the booking moved on in a way this guard did not anticipate.
|
||||
if (Number(booking.totalAmount ?? 0) > 0) {
|
||||
throw new BadRequestException(
|
||||
"This booking has already been priced — please contact Operations to cancel it.",
|
||||
);
|
||||
}
|
||||
|
||||
// The reason lives on the booking's review-note log, the same place the
|
||||
// customer cancel path records it — there is no column for it.
|
||||
await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.save({
|
||||
bookingId,
|
||||
note: reason?.trim() || "Cancelled by the shipping line",
|
||||
type: "REJECTION",
|
||||
authorId: userId,
|
||||
} as never);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
|
||||
return this.findMine(userId, bookingId);
|
||||
}
|
||||
|
||||
/** Mirrors the customer reference format — one booking sequence per year. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const { max } = (await this.bookingsRepository
|
||||
.createQueryBuilder("b")
|
||||
.select(
|
||||
`COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`,
|
||||
"max",
|
||||
)
|
||||
.where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` })
|
||||
.getRawOne<{ max: number }>()) ?? { max: 0 };
|
||||
|
||||
return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import {
|
||||
RegisterShippingLineResponseDto,
|
||||
ShippingLineResponseDto,
|
||||
} from "./dto/shipping-line-response.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Shipping line *companies* — carriers with a portal login, registered by staff
|
||||
* (there is no self-signup). The line receives a single-use activation link and
|
||||
* sets its own password, so staff never see or handle a credential.
|
||||
*
|
||||
* Distinct from `freight.shipping_lines` behind `/shipping-lines`
|
||||
* (rule-engine): that is a pricing lookup list — a code/label a booking points
|
||||
* at via `shipping_line_id` — with no account, no user and no login. Same words,
|
||||
* different concept, hence the separate route.
|
||||
*/
|
||||
@ApiTags("shipping-line-companies")
|
||||
@Controller("shipping-line-companies")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCompaniesController {
|
||||
constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.create)
|
||||
@ApiOperation({
|
||||
summary: "Register a shipping line and send its activation link",
|
||||
})
|
||||
async register(
|
||||
@Body() dto: CreateShippingLineDto,
|
||||
): Promise<RegisterShippingLineResponseDto> {
|
||||
const { shippingLine, activationSentTo } =
|
||||
await this.shippingLineCompaniesService.register(dto);
|
||||
return new RegisterShippingLineResponseDto(shippingLine, activationSentTo);
|
||||
}
|
||||
|
||||
@Get()
|
||||
// OR'd: the credits view needs this list as its line picker, so holding
|
||||
// shipping_line_credits:view alone is enough to read it.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.shippingLines.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
])
|
||||
@ApiOperation({ summary: "List shipping lines (paginated)" })
|
||||
async list(
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
): Promise<{
|
||||
items: ShippingLineResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const result = await this.shippingLineCompaniesService.list(
|
||||
page ? Number(page) : undefined,
|
||||
limit ? Number(limit) : undefined,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item) => new ShippingLineResponseDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||
@ApiOperation({ summary: "Get a shipping line by id" })
|
||||
async findOne(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ShippingLineResponseDto> {
|
||||
return new ShippingLineResponseDto(
|
||||
await this.shippingLineCompaniesService.findById(id),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/resend-activation")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Resend a shipping line's activation / password-reset link",
|
||||
})
|
||||
async resendActivation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
return this.shippingLineCompaniesService.resendActivation(id, dto.channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
import { ShippingLineCreditsController } from "./shipping-line-credits.controller";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Booking is registered here only so this module can create shipping-line
|
||||
// rows in `freight.bookings`; the customer BookingsModule is untouched.
|
||||
TypeOrmModule.forFeature([
|
||||
ShippingLineCompany,
|
||||
ShippingLineCredit,
|
||||
ShippingLineInvoiceApproval,
|
||||
User,
|
||||
Booking,
|
||||
]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
// Credits are billed by generating an ordinary invoice. Billing still knows
|
||||
// nothing about credits and hears about settlement only by emitting its own
|
||||
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
|
||||
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
|
||||
forwardRef(() => BillingModule),
|
||||
],
|
||||
controllers: [
|
||||
ShippingLineCompaniesController,
|
||||
ShippingLineBookingsController,
|
||||
ShippingLineCreditsController,
|
||||
],
|
||||
providers: [
|
||||
ShippingLineCompaniesService,
|
||||
ShippingLineCompaniesRepository,
|
||||
ShippingLineBookingsService,
|
||||
ShippingLineCreditsService,
|
||||
ShippingLineCreditsRepository,
|
||||
ShippingLineInvoiceApprovalsRepository,
|
||||
],
|
||||
// Exported so whatever prices a shipping-line booking can record the charge.
|
||||
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
|
||||
})
|
||||
export class ShippingLineCompaniesModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesRepository extends BaseRepository<ShippingLineCompany> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCompany)
|
||||
private readonly shippingLineRepo: Repository<ShippingLineCompany>,
|
||||
) {
|
||||
super(shippingLineRepo);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineRepo.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
/** Case-insensitive, matching the `lower(email)` unique index. */
|
||||
async existsByEmail(email: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("lower(sl.email) = lower(:email)", { email })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsByScac(scacCode: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("upper(sl.scacCode) = upper(:scacCode)", { scacCode })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findAllPaginated(
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[ShippingLineCompany[], number]> {
|
||||
return this.shippingLineRepo.findAndCount({
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert inside a caller-supplied transaction, so the shipping-line row and
|
||||
* the IAM user it points at commit together — a row referencing a user that
|
||||
* was rolled back (or vice versa) is an account nobody can sign in to.
|
||||
*/
|
||||
createInTransaction(
|
||||
manager: EntityManager,
|
||||
data: Partial<ShippingLineCompany>,
|
||||
): Promise<ShippingLineCompany> {
|
||||
const repo = manager.getRepository(ShippingLineCompany);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { ConflictException } from "@nestjs/common";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Registration is the whole feature: an IAM account and a carrier record
|
||||
* created together, then an activation link the line uses to set its own
|
||||
* password. These lock the parts that would silently break the login.
|
||||
*/
|
||||
describe("ShippingLineCompaniesService.register", () => {
|
||||
const savedUser = { id: "user-1" };
|
||||
|
||||
let shippingLinesRepo: {
|
||||
existsByEmail: jest.Mock;
|
||||
existsByScac: jest.Mock;
|
||||
createInTransaction: jest.Mock;
|
||||
findById: jest.Mock;
|
||||
findByUserId: jest.Mock;
|
||||
};
|
||||
let userRepository: { findOne: jest.Mock };
|
||||
let customerResetService: { sendResetLinkToUser: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
|
||||
let service: ShippingLineCompaniesService;
|
||||
|
||||
const dto = {
|
||||
name: "Ethiopian Shipping Lines",
|
||||
email: "Ops@ESL.com.et",
|
||||
phoneNumber: "+251911223344",
|
||||
scacCode: "eslk",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepoInTx = {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn().mockResolvedValue(savedUser),
|
||||
};
|
||||
|
||||
shippingLinesRepo = {
|
||||
existsByEmail: jest.fn().mockResolvedValue(false),
|
||||
existsByScac: jest.fn().mockResolvedValue(false),
|
||||
createInTransaction: jest
|
||||
.fn()
|
||||
.mockImplementation((_m, data) => ({ id: "sl-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
userRepository = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
customerResetService = {
|
||||
sendResetLinkToUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) =>
|
||||
cb({ getRepository: () => userRepoInTx }),
|
||||
),
|
||||
};
|
||||
|
||||
service = new ShippingLineCompaniesService(
|
||||
shippingLinesRepo as never,
|
||||
userRepository as never,
|
||||
customerResetService as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the IAM account with no password set", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const created = userRepoInTx.create.mock.calls[0][0];
|
||||
expect(created).toMatchObject({
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
// The line sets its own password from the activation link. Employee
|
||||
// creation seeds a shared default here; a shipping line must not get one.
|
||||
hasSetPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never writes a credential row", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
// Only the User repository is touched inside the transaction — a
|
||||
// UserCredential insert would mean the account has a password nobody chose.
|
||||
for (const call of userRepoInTx.save.mock.calls) {
|
||||
expect(call[0]).not.toHaveProperty("password");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalises email and SCAC before storing", async () => {
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
expect(result.shippingLine).toMatchObject({
|
||||
email: "ops@esl.com.et",
|
||||
scacCode: "ESLK",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the account and the record in one transaction", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the activation link outside the transaction, after commit", async () => {
|
||||
const order: string[] = [];
|
||||
dataSource.transaction.mockImplementation(async (cb: never) => {
|
||||
order.push("tx");
|
||||
return (cb as unknown as (m: unknown) => Promise<unknown>)({
|
||||
getRepository: () => userRepoInTx,
|
||||
});
|
||||
});
|
||||
customerResetService.sendResetLinkToUser.mockImplementation(async () => {
|
||||
order.push("send");
|
||||
return { maskedTarget: "o**@esl.com.et", channel: "email" };
|
||||
});
|
||||
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(order[0]).toBe("tx");
|
||||
expect(order).toContain("send");
|
||||
});
|
||||
|
||||
it("emails the link, and also texts it when the number is domestic", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toContain(ResetChannel.Email);
|
||||
expect(channels).toContain(ResetChannel.Phone);
|
||||
});
|
||||
|
||||
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
|
||||
await service.register({ ...dto, phoneNumber: "+441234567890" } as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toEqual([ResetChannel.Email]);
|
||||
});
|
||||
|
||||
it("keeps the registration when the activation link fails to send", async () => {
|
||||
customerResetService.sendResetLinkToUser.mockResolvedValue(null);
|
||||
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
// The account is valid without the link and the link is resendable —
|
||||
// a delivery failure must not roll back the registration.
|
||||
expect(result.shippingLine).toMatchObject({ id: "sl-1" });
|
||||
expect(result.activationSentTo).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a duplicate email", async () => {
|
||||
shippingLinesRepo.existsByEmail.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a duplicate SCAC", async () => {
|
||||
shippingLinesRepo.existsByScac.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses credentials already belonging to another account", async () => {
|
||||
// Reusing an existing IAM user would let one login resolve to both a
|
||||
// customer and a shipping line.
|
||||
userRepository.findOne.mockResolvedValue({ id: "existing" });
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults the username to the email", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({
|
||||
username: "ops@esl.com.et",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The default reset lookup inner-joins an active `user_credentials` row so a
|
||||
* reset cannot revive a suspended account. A shipping line has no credential
|
||||
* until it uses the activation link, so without this flag the account is
|
||||
* excluded from its own activation — the link is never minted, never logged,
|
||||
* and resend answers 404.
|
||||
*/
|
||||
it("requests the credential-less lookup for every activation send", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled();
|
||||
for (const call of customerResetService.sendResetLinkToUser.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ allowWithoutCredential: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requests the credential-less lookup when resending", async () => {
|
||||
shippingLinesRepo.findById.mockResolvedValue({
|
||||
id: "sl-1",
|
||||
userId: "user-1",
|
||||
phoneNumber: "+251911223344",
|
||||
});
|
||||
|
||||
await service.resendActivation("sl-1", ResetChannel.Email);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
ResetChannel.Email,
|
||||
expect.objectContaining({ allowWithoutCredential: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
|
||||
export interface RegisteredShippingLine {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/** Masked destination of the activation link, or null if none was sent. */
|
||||
activationSentTo: string | null;
|
||||
activationChannel: ResetChannel | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesService {
|
||||
private readonly logger = new Logger(ShippingLineCompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a shipping line: create its IAM account and its record together,
|
||||
* then send an activation link so the line sets its own password.
|
||||
*
|
||||
* The IAM mechanics follow `BackofficeService.createOrganizationUser` — same
|
||||
* entities, same transaction shape — with one deliberate difference: no
|
||||
* `UserCredential` row is written and `hasSetPassword` stays false. Staff
|
||||
* creating an employee seed a shared default password; a shipping line must
|
||||
* come through the activation link instead, so no credential exists until the
|
||||
* line sets one.
|
||||
*/
|
||||
async register(dto: CreateShippingLineDto): Promise<RegisteredShippingLine> {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = (dto.username?.trim() || email).toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
const scacCode = dto.scacCode?.trim().toUpperCase();
|
||||
|
||||
if (await this.shippingLineCompaniesRepo.existsByEmail(email)) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with SCAC ${scacCode} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
// An existing IAM account means these credentials already belong to a
|
||||
// customer or an employee. Reusing it would let one login resolve to two
|
||||
// different account kinds, so this is refused rather than merged — unlike
|
||||
// employee creation, which legitimately re-uses a person's existing user.
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException(
|
||||
"email_or_username_already_in_use",
|
||||
);
|
||||
}
|
||||
|
||||
const shippingLine = await this.dataSource.transaction(async (manager) => {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
name: { en: dto.name.trim() },
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
// No credential row is written: the account has no password until the
|
||||
// activation link is used. `hasSetPassword` must stay false or the
|
||||
// portal treats the account as ready to sign in with a password that
|
||||
// does not exist.
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.shippingLineCompaniesRepo.createInTransaction(manager, {
|
||||
userId: user.id as string,
|
||||
name: dto.name.trim(),
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
scacCode: scacCode ?? null,
|
||||
imoNumber: dto.imoNumber?.trim() || null,
|
||||
bicCode: dto.bicCode?.trim() || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||
// a registered line. The link is resendable, and the account is already
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(shippingLine);
|
||||
|
||||
return {
|
||||
shippingLine,
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the activation link on registration.
|
||||
*
|
||||
* Email always goes out — it is required at registration and is the only
|
||||
* channel guaranteed to reach a foreign-registered line. SMS is sent in
|
||||
* addition when the number is domestic, since the gateway silently drops
|
||||
* anything else (see `CustomerResetService`). Two links are two independent
|
||||
* single-use tickets; whichever the line opens first works.
|
||||
*
|
||||
* Reports the email send, as that is the one that is always attempted.
|
||||
*/
|
||||
async sendActivationLink(shippingLine: ShippingLineCompany) {
|
||||
const scope = `shipping line ${shippingLine.id}`;
|
||||
|
||||
const emailed = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
ResetChannel.Email,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
if (!emailed) {
|
||||
this.logger.error(
|
||||
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
|
||||
const texted = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
ResetChannel.Phone,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
if (!texted) {
|
||||
this.logger.warn(
|
||||
`Activation SMS not sent for shipping line ${shippingLine.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return emailed;
|
||||
}
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
|
||||
if (
|
||||
channel === ResetChannel.Phone &&
|
||||
(!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This shipping line has no domestic phone number — the SMS gateway cannot reach it",
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
channel,
|
||||
{ scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active account with ${
|
||||
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||
} for this shipping line`,
|
||||
);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ShippingLineCompany> {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/** The shipping line signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineCompaniesRepo.findByUserId(userId);
|
||||
}
|
||||
|
||||
async list(page = 1, limit = 20) {
|
||||
const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated(
|
||||
(page - 1) * limit,
|
||||
limit,
|
||||
);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import {
|
||||
CancelCreditDto,
|
||||
DecideInvoiceActionDto,
|
||||
GenerateCreditInvoiceDto,
|
||||
RequestInvoiceActionDto,
|
||||
} from "./dto/shipping-line-credit.dto";
|
||||
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe.
|
||||
*
|
||||
* A shipping line books and ships without paying — the charge is recorded as a
|
||||
* credit instead. Finance reads the unbilled list here, batches it into an
|
||||
* invoice, and the line then pays that invoice through the ordinary
|
||||
* `/billing` + CBE routes; nothing in this controller touches money directly.
|
||||
*/
|
||||
@ApiTags("shipping-line-credits")
|
||||
@Controller("shipping-line-credits")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCreditsController {
|
||||
constructor(private readonly credits: ShippingLineCreditsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
|
||||
})
|
||||
async listAll(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listAll(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "summary" is never
|
||||
// captured as a shipping-line id.
|
||||
@Get("summary")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
|
||||
})
|
||||
async summaryAll(
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.summary(shippingLineId);
|
||||
}
|
||||
|
||||
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
|
||||
@Get("invoices")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
|
||||
})
|
||||
async listInvoices(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listCreditInvoices(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status as Freight.InvoiceStatus | undefined,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("invoice-actions/pending")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
|
||||
})
|
||||
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
|
||||
const ids = (invoiceIds ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
return this.credits.pendingInvoiceActions(ids);
|
||||
}
|
||||
|
||||
// ── Maker–checker on credit invoices ──────────────────────────────────────
|
||||
// Request and approve are DIFFERENT permissions, and the service refuses a
|
||||
// decision by the requester — marking debt paid or voiding an invoice is
|
||||
// never a one-person action.
|
||||
|
||||
@Post("invoices/:invoiceId/mark-paid-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
|
||||
})
|
||||
async requestMarkPaid(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.MarkPaid,
|
||||
user.id,
|
||||
dto.reason,
|
||||
dto.paymentReference,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoices/:invoiceId/cancel-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
|
||||
})
|
||||
async requestCancel(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.Cancel,
|
||||
user.id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
|
||||
})
|
||||
async approveInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
true,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending invoice request — nothing is changed.",
|
||||
})
|
||||
async rejectInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
false,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "me" is never captured
|
||||
// as a shipping-line id.
|
||||
@Get("me")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The signed-in shipping line's own statement: outstanding balance plus its credit ledger.",
|
||||
})
|
||||
async myStatement(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.credits.myStatement(
|
||||
user.id,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/outstanding")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"What one shipping line owes: unbilled + billed totals, derived from the ledger.",
|
||||
})
|
||||
async outstanding(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.outstanding(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/unbilled")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credits that can go on an invoice for this line, oldest first. This is the selection list.",
|
||||
})
|
||||
async listUnbilled(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.listUnbilled(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary: "Full credit ledger for one shipping line (paginated).",
|
||||
})
|
||||
async listCredits(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
return this.credits.listCredits(
|
||||
shippingLineId,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.",
|
||||
})
|
||||
async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) {
|
||||
return this.credits.generateInvoice(dto.creditIds, {
|
||||
dueInDays: dto.dueInDays,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":creditId/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Write off an unbilled credit. Once billed, cancel the invoice instead.",
|
||||
})
|
||||
async cancel(
|
||||
@Param("creditId", ParseUUIDPipe) creditId: string,
|
||||
@Body() dto: CancelCreditDto,
|
||||
) {
|
||||
return this.credits.cancelCredit(creditId, dto.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
OUTSTANDING_CREDIT_STATUSES,
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
/** Priced but not yet on an invoice. */
|
||||
unbilledAmount: number;
|
||||
/** On an issued invoice, awaiting payment. */
|
||||
billedAmount: number;
|
||||
/** `unbilledAmount + billedAmount` — the full debt. */
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCredit> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCredit)
|
||||
private readonly credits: Repository<ShippingLineCredit>,
|
||||
) {
|
||||
super(credits);
|
||||
}
|
||||
|
||||
findByBookingId(bookingId: string): Promise<ShippingLineCredit | null> {
|
||||
return this.credits.findOne({ where: { bookingId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's worklist: everything for one line that can go on an invoice,
|
||||
* oldest first so the longest-standing debt is billed before newer charges.
|
||||
*/
|
||||
findUnbilled(shippingLineCompanyId: string): Promise<ShippingLineCredit[]> {
|
||||
return this.credits.find({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
},
|
||||
relations: { booking: true },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByInvoiceId(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
const repo = manager
|
||||
? manager.getRepository(ShippingLineCredit)
|
||||
: this.credits;
|
||||
return repo.find({ where: { invoiceId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific batch inside the caller's transaction and lock it, so two
|
||||
* concurrent invoice generations cannot both claim the same credits.
|
||||
*/
|
||||
findByIdsForUpdate(
|
||||
manager: EntityManager,
|
||||
ids: string[],
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
return manager.getRepository(ShippingLineCredit).find({
|
||||
where: { id: In(ids) },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived debt — never a stored column. Grouped in one query so the detail
|
||||
* page does not fan out per status. Without a line id it totals every line —
|
||||
* the back-office overview figure.
|
||||
*/
|
||||
async outstandingFor(
|
||||
shippingLineCompanyId?: string,
|
||||
): Promise<OutstandingTotals> {
|
||||
const qb = this.credits
|
||||
.createQueryBuilder("credit")
|
||||
.select("credit.status", "status")
|
||||
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("credit.status IN (:...statuses)", {
|
||||
statuses: [...OUTSTANDING_CREDIT_STATUSES],
|
||||
})
|
||||
.andWhere("credit.deletedAt IS NULL")
|
||||
.groupBy("credit.status");
|
||||
if (shippingLineCompanyId) {
|
||||
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||
shippingLineCompanyId,
|
||||
});
|
||||
}
|
||||
const rows = await qb.getRawMany<{
|
||||
status: string;
|
||||
amount: string;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
const totals = (status: ShippingLineCreditStatus) => {
|
||||
const row = rows.find((r) => r.status === status);
|
||||
return {
|
||||
amount: row ? Number(row.amount) : 0,
|
||||
count: row ? Number(row.count) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const unbilled = totals(ShippingLineCreditStatus.Unbilled);
|
||||
const billed = totals(ShippingLineCreditStatus.Billed);
|
||||
|
||||
return {
|
||||
unbilledAmount: unbilled.amount,
|
||||
billedAmount: billed.amount,
|
||||
totalOutstanding: unbilled.amount + billed.amount,
|
||||
unbilledCount: unbilled.count,
|
||||
billedCount: billed.count,
|
||||
currency: "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated ledger — every credit, whatever its status. Scoped to one line
|
||||
* when an id is given, across all lines otherwise.
|
||||
*/
|
||||
findAllPaginated(
|
||||
shippingLineCompanyId: string | undefined,
|
||||
skip: number,
|
||||
take: number,
|
||||
status?: ShippingLineCreditStatus,
|
||||
): Promise<[ShippingLineCredit[], number]> {
|
||||
return this.credits.findAndCount({
|
||||
where: {
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
relations: { booking: true, invoice: true, shippingLineCompany: true },
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
/**
|
||||
* The money path: a shipping line ships without paying, so the debt lives
|
||||
* entirely in these three transitions. Each test below locks one way the debt
|
||||
* could be lost or double-counted.
|
||||
*/
|
||||
describe("ShippingLineCreditsService", () => {
|
||||
let creditsRepo: {
|
||||
findByIdsForUpdate: jest.Mock;
|
||||
findUnbilled: jest.Mock;
|
||||
outstandingFor: jest.Mock;
|
||||
findAllPaginated: jest.Mock;
|
||||
};
|
||||
let billing: { generateInvoice: jest.Mock };
|
||||
let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock; getRepository: jest.Mock };
|
||||
let mg: {
|
||||
findOne: jest.Mock;
|
||||
getRepository: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let updateResult: { affected: number };
|
||||
let service: ShippingLineCreditsService;
|
||||
|
||||
const booking = {
|
||||
id: "booking-1",
|
||||
reference: "BK-2026-000001",
|
||||
shippingLineCompanyId: "sl-1",
|
||||
} as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
txRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ id: "credit-1", ...v })),
|
||||
};
|
||||
mg = {
|
||||
findOne: jest.fn().mockResolvedValue(booking),
|
||||
getRepository: jest.fn(() => txRepo),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
updateResult = { affected: 2 };
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) => cb(mg)),
|
||||
getRepository: jest.fn(() => ({
|
||||
update: jest.fn().mockResolvedValue(updateResult),
|
||||
})),
|
||||
};
|
||||
creditsRepo = {
|
||||
findByIdsForUpdate: jest.fn(),
|
||||
findUnbilled: jest.fn(),
|
||||
outstandingFor: jest.fn(),
|
||||
findAllPaginated: jest.fn(),
|
||||
};
|
||||
billing = {
|
||||
generateInvoice: jest.fn().mockResolvedValue({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
totalAmount: 50000,
|
||||
}),
|
||||
};
|
||||
shippingLines = {
|
||||
findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
service = new ShippingLineCreditsService(
|
||||
dataSource as never,
|
||||
creditsRepo as never,
|
||||
// Approvals repo — only the invoice maker–checker paths touch it.
|
||||
{
|
||||
findPendingByInvoice: jest.fn(),
|
||||
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
|
||||
findByIdForUpdate: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as never,
|
||||
billing as never,
|
||||
shippingLines as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("recordCredit", () => {
|
||||
it("records the charge against the booking's own shipping line", async () => {
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(txRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// Taken from the booking, never from the caller.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
expect(credit.id).toBe("credit-1");
|
||||
});
|
||||
|
||||
it("is idempotent per booking — a retried pricing step cannot double the debt", async () => {
|
||||
const existing = {
|
||||
id: "credit-existing",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
amount: 20000,
|
||||
currency: "ETB",
|
||||
};
|
||||
txRepo.findOne.mockResolvedValue(existing);
|
||||
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(credit).toBe(existing);
|
||||
expect(txRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a customer booking — those are paid up front, not on credit", async () => {
|
||||
mg.findOne.mockResolvedValue({
|
||||
...booking,
|
||||
shippingLineCompanyId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: 100 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects a negative amount", async () => {
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: -1 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateInvoice", () => {
|
||||
const unbilled = (id: string, amount: number) => ({
|
||||
id,
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: `booking-${id}`,
|
||||
amount,
|
||||
currency: "ETB",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
description: `Freight service — ${id}`,
|
||||
});
|
||||
|
||||
it("bills the batch as one invoice and flips the credits to BILLED", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
unbilled("c2", 30000),
|
||||
]);
|
||||
|
||||
const invoice = await service.generateInvoice(["c1", "c2"]);
|
||||
|
||||
expect(billing.generateInvoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// The payer, not a customer — invoices.company_id stays null.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
sourceId: "sl-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
lines: [
|
||||
expect.objectContaining({ amount: 20000 }),
|
||||
expect.objectContaining({ amount: 30000 }),
|
||||
],
|
||||
}),
|
||||
mg,
|
||||
);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
}),
|
||||
);
|
||||
expect(invoice.id).toBe("inv-1");
|
||||
});
|
||||
|
||||
it("refuses to bill a credit that is already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed },
|
||||
]);
|
||||
|
||||
await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix two shipping lines on one invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix currencies", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 300), currency: "USD" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("reports ids that do not exist rather than silently billing the rest", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "missing"]),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("rejects an empty selection", async () => {
|
||||
await expect(service.generateInvoice([])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInvoicePaid", () => {
|
||||
it("clears every billed credit on the settled invoice", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 2 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
} as never);
|
||||
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
// Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt.
|
||||
{ invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed },
|
||||
expect.objectContaining({ status: ShippingLineCreditStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op on webhook redelivery", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 0 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await expect(
|
||||
service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-1",
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelCredit", () => {
|
||||
it("writes off an unbilled credit", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ id: "c1", status: ShippingLineCreditStatus.Unbilled },
|
||||
]);
|
||||
|
||||
const result = await service.cancelCredit("c1", "Booking voided");
|
||||
|
||||
expect(result.status).toBe(ShippingLineCreditStatus.Cancelled);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
{ id: "c1" },
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancellationReason: "Booking voided",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to write off a credit already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{
|
||||
id: "c1",
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.cancelCredit("c1", "oops"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,780 @@
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
ShippingLineInvoiceActionType,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/** A charge to record against a shipping line's booking. */
|
||||
export interface RecordCreditInput {
|
||||
bookingId: string;
|
||||
/** Frozen at this value; never recalculated afterwards. */
|
||||
amount: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Payment terms for a generated shipping-line invoice. */
|
||||
export interface GenerateCreditInvoiceOptions {
|
||||
/** Pay window in days; defaults to the billing module's own default. */
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by the booking-transition accept path for shipping-line bookings.
|
||||
* An event rather than a service call: BookingsModule cannot import the
|
||||
* shipping-line modules without closing a module cycle.
|
||||
*/
|
||||
export interface ShippingLineBookingAcceptedPayload {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
/** The booking's priced total, frozen at completion time. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
*
|
||||
* Three moments, in order:
|
||||
*
|
||||
* 1. **Charge.** A shipping line's booking is priced, and
|
||||
* {@link recordCredit} writes an UNBILLED credit. No invoice, no payment
|
||||
* intent, no gate on the booking — it proceeds regardless.
|
||||
* 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and
|
||||
* {@link generateInvoice} turns them into a single invoice, one line per
|
||||
* credit. The credits become BILLED.
|
||||
* 3. **Settle.** The line pays that invoice through the ordinary CBE flow.
|
||||
* Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid}
|
||||
* marks the batch PAID, and the debt disappears.
|
||||
*
|
||||
* Nothing here decrements a balance: the amount owed is always
|
||||
* `SUM(amount)` over non-terminal credits. Payment is settled by the gateway
|
||||
* webhook alone — no manual approval step — so a credit only ever leaves debt
|
||||
* because real money arrived.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsService {
|
||||
private readonly logger = new Logger(ShippingLineCreditsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly credits: ShippingLineCreditsRepository,
|
||||
private readonly approvals: ShippingLineInvoiceApprovalsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly shippingLines: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
// ── 1. Charge ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record what a shipping line owes for one booking.
|
||||
*
|
||||
* Called when the booking is priced. The owner is read off the booking
|
||||
* itself rather than passed in, so a credit can never be filed against the
|
||||
* wrong line. Idempotent per booking: a second call returns the existing
|
||||
* credit untouched rather than doubling the debt — safe against a retried
|
||||
* pricing step, and the partial unique index backs it at the DB level.
|
||||
*
|
||||
* Pass `manager` to enlist in the caller's transaction, so the credit and
|
||||
* whatever priced the booking commit together.
|
||||
*/
|
||||
async recordCredit(
|
||||
input: RecordCreditInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit> {
|
||||
if (!(input.amount >= 0)) {
|
||||
throw new BadRequestException("Credit amount cannot be negative.");
|
||||
}
|
||||
|
||||
const run = async (mg: EntityManager): Promise<ShippingLineCredit> => {
|
||||
const booking = await mg.findOne(Booking, {
|
||||
where: { id: input.bookingId },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${input.bookingId} not found`);
|
||||
}
|
||||
if (!booking.shippingLineCompanyId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`,
|
||||
);
|
||||
}
|
||||
|
||||
const repo = mg.getRepository(ShippingLineCredit);
|
||||
const existing = await repo.findOne({
|
||||
where: { bookingId: input.bookingId },
|
||||
});
|
||||
if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) {
|
||||
this.logger.warn(
|
||||
`Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const credit = await repo.save(
|
||||
repo.create({
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
bookingId: input.bookingId,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? "ETB",
|
||||
description:
|
||||
input.description ?? `Freight service — booking ${booking.reference}`,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
creditId: credit.id,
|
||||
bookingId: credit.bookingId,
|
||||
shippingLineCompanyId: credit.shippingLineCompanyId,
|
||||
amount: credit.amount,
|
||||
},
|
||||
{ path: "shippingLineCredit.recorded" },
|
||||
);
|
||||
|
||||
return credit;
|
||||
};
|
||||
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a shipping-line booking becomes debt: Operations accepted it.
|
||||
* Swallows its own failures with a loud log instead of throwing — the accept
|
||||
* has already committed, and failing the staff response for a ledger write
|
||||
* would present a succeeded accept as an error. `recordCredit` is idempotent
|
||||
* per booking, so a re-accepted (previously reverted) booking cannot double
|
||||
* the debt.
|
||||
*/
|
||||
@OnEvent("shipping_line_booking.accepted")
|
||||
async onBookingAccepted(
|
||||
payload: ShippingLineBookingAcceptedPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.recordCredit({
|
||||
bookingId: payload.bookingId,
|
||||
amount: payload.amount,
|
||||
// Shipping lines are always billed in ETB (enforced at completion).
|
||||
currency: "ETB",
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Bill ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Turn a batch of unbilled credits into one invoice.
|
||||
*
|
||||
* Every credit must belong to the SAME shipping line — one invoice has one
|
||||
* payer, so a mixed batch is rejected rather than silently split. The whole
|
||||
* thing runs in one transaction with the credits locked FOR UPDATE, so two
|
||||
* finance users clicking at once cannot bill the same credit twice: the
|
||||
* second transaction blocks, then finds the rows already BILLED and fails.
|
||||
*/
|
||||
async generateInvoice(
|
||||
creditIds: string[],
|
||||
options: GenerateCreditInvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
if (creditIds.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Select at least one credit to invoice.",
|
||||
);
|
||||
}
|
||||
const uniqueIds = [...new Set(creditIds)];
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds);
|
||||
|
||||
const missing = uniqueIds.filter(
|
||||
(id) => !credits.some((c) => c.id === id),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new NotFoundException(
|
||||
`Credit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alreadyBilled = credits.filter(
|
||||
(c) => c.status !== ShippingLineCreditStatus.Unbilled,
|
||||
);
|
||||
if (alreadyBilled.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled
|
||||
.map((c) => `${c.id} (${c.status})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId));
|
||||
if (lineIds.size > 1) {
|
||||
throw new BadRequestException(
|
||||
"All selected credits must belong to the same shipping line — one invoice has one payer.",
|
||||
);
|
||||
}
|
||||
const shippingLineCompanyId = credits[0].shippingLineCompanyId;
|
||||
|
||||
const currencies = new Set(credits.map((c) => c.currency));
|
||||
if (currencies.size > 1) {
|
||||
throw new BadRequestException(
|
||||
`Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const currency = credits[0].currency;
|
||||
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: InvoiceLineInput[] = credits.map((credit) => ({
|
||||
chargeType: "SHIPPING_LINE_SERVICE",
|
||||
description: credit.description ?? undefined,
|
||||
quantity: 1,
|
||||
unitRate: Number(credit.amount),
|
||||
amount: Number(credit.amount),
|
||||
currency: credit.currency,
|
||||
metadata: { creditId: credit.id, bookingId: credit.bookingId },
|
||||
}));
|
||||
|
||||
const invoice = await this.billing.generateInvoice(
|
||||
{
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// Unlike other sources this is the payer, not a single billed
|
||||
// record: the invoice spans many bookings, and each credit keeps its
|
||||
// own booking link.
|
||||
sourceId: shippingLineCompanyId,
|
||||
type: "SHIPPING_LINE_CREDIT",
|
||||
shippingLineCompanyId,
|
||||
currency,
|
||||
lines,
|
||||
dueInDays: options.dueInDays,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
},
|
||||
mg,
|
||||
);
|
||||
|
||||
const billedAt = new Date();
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: In(credits.map((c) => c.id)) },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: invoice.id,
|
||||
billedAt,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
shippingLineCompanyId,
|
||||
creditCount: credits.length,
|
||||
totalAmount: invoice.totalAmount,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiced" },
|
||||
);
|
||||
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Settle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear the batch once its invoice is paid.
|
||||
*
|
||||
* Driven by the billing event rather than a call inside the payment path, so
|
||||
* the CBE webhook flow needs no knowledge of credits: whatever settles the
|
||||
* invoice — gateway webhook, or a finance-recorded offline payment — this
|
||||
* fires. Idempotent, because a redelivered webhook re-emits the event.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.paid")
|
||||
async onInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{ status: ShippingLineCreditStatus.Paid, paidAt: new Date() },
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsCleared: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.settled" },
|
||||
);
|
||||
|
||||
// Zero is the ordinary idempotent no-op on webhook redelivery. It is only
|
||||
// worth a line in the log, not an error: the invoice is paid either way.
|
||||
if (!result.affected) {
|
||||
this.logger.log(
|
||||
`Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Finance's worklist: what can go on an invoice for this line right now. */
|
||||
async listUnbilled(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const credits = await this.credits.findUnbilled(shippingLineCompanyId);
|
||||
return {
|
||||
items: credits,
|
||||
totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0),
|
||||
currency: credits[0]?.currency ?? "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/** The debt figure shown on the shipping-line detail page. */
|
||||
async outstanding(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-office overview: outstanding totals across every line, or one line
|
||||
* when an id is given.
|
||||
*/
|
||||
async summary(shippingLineCompanyId?: string) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole ledger across every shipping line, newest first — finance's
|
||||
* landing list. Optionally narrowed to one line and/or one status.
|
||||
*/
|
||||
async listAll(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** Full ledger for one line, newest first. */
|
||||
async listCredits(
|
||||
shippingLineCompanyId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in shipping line's own statement: what it owes and why.
|
||||
* Resolves the line from the session, so one line can never read another's.
|
||||
*/
|
||||
async myStatement(userId: string, page = 1, pageSize = 20) {
|
||||
const shippingLine = await this.shippingLines.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
const [outstanding, ledger] = await Promise.all([
|
||||
this.credits.outstandingFor(shippingLine.id),
|
||||
this.credits.findAllPaginated(
|
||||
shippingLine.id,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
outstanding,
|
||||
items: ledger[0],
|
||||
total: ledger[1],
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Credit invoices: list + maker–checker manual actions ─────────────────
|
||||
|
||||
/**
|
||||
* Staff list of the invoices minted from credit batches, each with its line
|
||||
* name and any undecided manual-action request attached — the data the
|
||||
* back-office actions column renders from.
|
||||
*/
|
||||
async listCreditInvoices(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: Freight.InvoiceStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
const [invoices, total] = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findAndCount({
|
||||
where: {
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const lineIds = [
|
||||
...new Set(
|
||||
invoices
|
||||
.map((inv) => inv.shippingLineCompanyId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const lines = lineIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.find({ where: { id: In(lineIds) } })
|
||||
: [];
|
||||
const nameById = new Map(lines.map((l) => [l.id, l.name]));
|
||||
|
||||
const pending = await this.approvals.findPendingByInvoiceIds(
|
||||
invoices.map((inv) => inv.id),
|
||||
);
|
||||
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
|
||||
|
||||
return {
|
||||
items: invoices.map((inv) => ({
|
||||
...inv,
|
||||
shippingLineName: inv.shippingLineCompanyId
|
||||
? (nameById.get(inv.shippingLineCompanyId) ?? null)
|
||||
: null,
|
||||
pendingAction: pendingByInvoice.get(inv.id) ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Undecided requests for a batch of invoices — feeds any invoice list. */
|
||||
async pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
// Bounded to a list page's worth of ids; anything larger is a misuse.
|
||||
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance raises a manual action on a credit invoice: record an offline
|
||||
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
|
||||
* yet — a chief with the matching approve permission decides it. One
|
||||
* undecided request per invoice (backed by a partial unique index).
|
||||
*/
|
||||
async requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: ShippingLineInvoiceActionType,
|
||||
requestedBy: string,
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
|
||||
throw new BadRequestException(
|
||||
"Manual actions here apply only to shipping-line credit invoices.",
|
||||
);
|
||||
}
|
||||
// Fast feedback only — the billing service re-validates authoritatively
|
||||
// (under lock) when the request is approved.
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.MarkPaid &&
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
) {
|
||||
throw new BadRequestException("Invoice is already paid.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Invoice is already cancelled.");
|
||||
}
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.Cancel &&
|
||||
Number(invoice.paidAmount) > 0
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
const existing = await this.approvals.findPendingByInvoice(invoiceId);
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`A ${existing.action} request is already awaiting decision on this invoice.`,
|
||||
);
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
invoiceId,
|
||||
action,
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
requestedBy,
|
||||
reason,
|
||||
paymentReference: paymentReference ?? null,
|
||||
});
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId: approval.id,
|
||||
invoiceId,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
action,
|
||||
requestedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.requested" },
|
||||
);
|
||||
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Gated purely by permission (the approve/reject
|
||||
* grants on the controller routes) — a decider holding the grant may decide
|
||||
* ANY pending request, their own included; that trade-off is deliberate.
|
||||
*
|
||||
* Approval executes the real action through the billing service AFTER the
|
||||
* decision row commits — its settlement/cancellation events must fire from
|
||||
* billing's own committed transaction (the credits listeners react to
|
||||
* them). If billing then rejects the action, the decision is compensated
|
||||
* back to PENDING so the request is not silently lost.
|
||||
*/
|
||||
async decideInvoiceAction(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
if (!approve && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A note is required when rejecting a request.",
|
||||
);
|
||||
}
|
||||
|
||||
const decided = await this.dataSource.transaction(async (mg) => {
|
||||
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Request ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`This request was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = approve
|
||||
? ShippingLineInvoiceActionStatus.Approved
|
||||
: ShippingLineInvoiceActionStatus.Rejected;
|
||||
await mg.update(
|
||||
ShippingLineInvoiceApproval,
|
||||
{ id: approvalId },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note ?? null,
|
||||
},
|
||||
);
|
||||
return { ...approval, status, decidedBy, decisionNote: note ?? null };
|
||||
});
|
||||
|
||||
if (!approve) {
|
||||
logCtx(
|
||||
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
|
||||
{ path: "shippingLineCredit.invoiceAction.rejected" },
|
||||
);
|
||||
return decided;
|
||||
}
|
||||
|
||||
try {
|
||||
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: decided.invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
|
||||
}
|
||||
// Full settlement of the outstanding balance; billing emits
|
||||
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
|
||||
await this.billing.recordPayment(decided.invoiceId, {
|
||||
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
method: "OFFLINE",
|
||||
reference: decided.paymentReference ?? undefined,
|
||||
metadata: {
|
||||
approvalId: decided.id,
|
||||
requestedBy: decided.requestedBy,
|
||||
approvedBy: decidedBy,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Billing emits `shipping_line_credit.invoice.cancelled`;
|
||||
// onInvoiceCancelled releases the credits back to the unbilled pool.
|
||||
await this.billing.cancelInvoice(decided.invoiceId);
|
||||
}
|
||||
} catch (err) {
|
||||
// The action was refused (state changed since the request — e.g. the
|
||||
// line paid through CBE in the meantime). Put the request back so it is
|
||||
// not recorded as approved-but-unexecuted.
|
||||
await this.approvals.update(approvalId, {
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId,
|
||||
invoiceId: decided.invoiceId,
|
||||
action: decided.action,
|
||||
decidedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.approved" },
|
||||
);
|
||||
|
||||
return decided;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a credit invoice is cancelled — through the approval flow or any
|
||||
* other billing path — its BILLED credits return to the unbilled pool so
|
||||
* the debt can be re-billed. The debt itself never disappears on invoice
|
||||
* cancellation; only {@link cancelCredit} writes debt off.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.cancelled")
|
||||
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
invoiceId: null,
|
||||
billedAt: null,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsReleased: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceCancelled.released" },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cancellation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write off an unbilled credit (booking voided, charge raised in error).
|
||||
* Only UNBILLED credits can be cancelled — once a credit is on an issued
|
||||
* invoice, the invoice is what has to be cancelled or credited, otherwise
|
||||
* the invoice total would stop matching the sum of its lines.
|
||||
*/
|
||||
async cancelCredit(
|
||||
creditId: string,
|
||||
reason: string,
|
||||
): Promise<ShippingLineCredit> {
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]);
|
||||
if (!credit) {
|
||||
throw new NotFoundException(`Credit ${creditId} not found`);
|
||||
}
|
||||
if (credit.status !== ShippingLineCreditStatus.Unbilled) {
|
||||
throw new BadRequestException(
|
||||
`Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: creditId },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancelledAt: new Date(),
|
||||
cancellationReason: reason,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...credit, status: ShippingLineCreditStatus.Cancelled };
|
||||
});
|
||||
}
|
||||
|
||||
private async requireShippingLine(shippingLineCompanyId: string) {
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineInvoiceApproval)
|
||||
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
|
||||
) {
|
||||
super(approvals);
|
||||
}
|
||||
|
||||
findPendingByInvoice(
|
||||
invoiceId: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return this.approvals.findOne({
|
||||
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
|
||||
});
|
||||
}
|
||||
|
||||
/** Pending requests for a page of invoices — one query, no N+1. */
|
||||
findPendingByInvoiceIds(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return this.approvals.find({
|
||||
where: {
|
||||
invoiceId: In(invoiceIds),
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Load one request inside the caller's transaction, locked for decision. */
|
||||
findByIdForUpdate(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
|
||||
where: { id },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||
|
||||
@@ -81,6 +82,19 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: string | null;
|
||||
|
||||
/**
|
||||
* Dedicates this departure to one shipping line. NULL = a normal train,
|
||||
* visible to customers as today. Set = the train is HIDDEN from every
|
||||
* customer-facing read (windows, day pools, home cards) and shown only to
|
||||
* this shipping line in its portal.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany)
|
||||
@JoinColumn({ name: 'shipping_line_company_id' })
|
||||
shippingLineCompany?: ShippingLineCompany | null;
|
||||
|
||||
/**
|
||||
* Reverse the wagon ORDER on this train: when true, the built wagon plan is
|
||||
* flipped at build so the physically-last wagon sits at position 1. Only the
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
} from 'typeorm';
|
||||
@@ -25,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { isRoadService } from '../bookings/road.util';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
@@ -153,6 +155,19 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
|
||||
export interface TrainOptionCargoOverrides {
|
||||
/** Container types drive the per-type space. */
|
||||
containerTypeIds?: string[];
|
||||
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||||
containerSizes?: string[];
|
||||
/** Bulk counterparts of the container inputs. */
|
||||
cargoTypeId?: string;
|
||||
cargoTypeCode?: string;
|
||||
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||||
wagons?: number;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
@@ -820,8 +835,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// stop order, so we fetch the day's open trains without endpoint filters.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
// A customer-picked train narrows the scan to that ONE schedule: export
|
||||
@@ -983,8 +999,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
@@ -1048,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async exportTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
overrides?: {
|
||||
/** Cargo the customer is entering on a form (bare contract instance —
|
||||
* nothing persisted yet): container types drive the per-type space. */
|
||||
containerTypeIds?: string[];
|
||||
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||||
containerSizes?: string[];
|
||||
/** Bulk counterparts of the container inputs. */
|
||||
cargoTypeId?: string;
|
||||
cargoTypeCode?: string;
|
||||
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||||
wagons?: number;
|
||||
},
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return this.buildTrainOptions(booking, candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same per-train wagon-availability cards, but for the trains DEDICATED
|
||||
* to a shipping line on the booking's lane + day. Same option shape as the
|
||||
* export picker so the portal reuses the same component; `isOpen`
|
||||
* additionally respects the dedicated close offset (windowClosesAt), since
|
||||
* these trains run no window cycle.
|
||||
*/
|
||||
async dedicatedTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string | null,
|
||||
shippingLineCompanyId: string,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const dedicated = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
|
||||
],
|
||||
});
|
||||
const candidates = dedicated
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
// A day narrows to that departure day; without one, every upcoming
|
||||
// departure on the lane is listed (the picker's full card list).
|
||||
(day
|
||||
? eatDay(s.scheduledDepartureDate) === day
|
||||
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
|
||||
(!booking.originYardId || s.originStationId === booking.originYardId) &&
|
||||
(!booking.destinationYardId ||
|
||||
s.destinationStationId === booking.destinationYardId),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
const options = await this.buildTrainOptions(booking, candidates);
|
||||
const now = Date.now();
|
||||
return options.map((o) => ({
|
||||
...o,
|
||||
isOpen:
|
||||
o.isOpen &&
|
||||
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
|
||||
private async withCargoOverrides(
|
||||
booking: Booking,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<Booking> {
|
||||
const sizeFts = (overrides?.containerSizes ?? [])
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
@@ -1092,25 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (overrides?.wagons && overrides.wagons > 0) {
|
||||
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
||||
}
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** One availability card per candidate schedule — the export picker's math. */
|
||||
private async buildTrainOptions(
|
||||
booking: Booking,
|
||||
candidates: TrainSchedule[],
|
||||
): Promise<ExportTrainOption[]> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
|
||||
const neededWagons = this.wagonsFor(booking, wagonDims);
|
||||
@@ -1219,8 +1290,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor.filter(
|
||||
@@ -2365,11 +2437,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
// Dedicated shipping-line trains never join the customer day pool.
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -3100,8 +3175,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const today = eatDay(new Date());
|
||||
@@ -3176,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
|
||||
* dedicated train for the booking's lane and shipment day.
|
||||
*
|
||||
* Runs at operation-accept: shipping lines pay later on the credit ledger,
|
||||
* so there is no pay window between accept and wagon placement — the
|
||||
* booking boards its train immediately. Customer bookings never come here;
|
||||
* they keep the batch pool → reserve → pay → allocate pipeline.
|
||||
*
|
||||
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
|
||||
* (without the PAID stamps the customer hold writes — nothing was paid).
|
||||
* No dedicated train on the day is not an error: the booking simply stays
|
||||
* in the ordinary day pool for the batch engine.
|
||||
*/
|
||||
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
|
||||
if (isRoadService(booking.serviceType)) return;
|
||||
|
||||
const day = eatDay(booking.scheduledDate);
|
||||
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: [
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const target = dedicated.find(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
|
||||
);
|
||||
if (!target) {
|
||||
this.logger.log(
|
||||
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
|
||||
`train on ${day} — left in the day pool for the batch engine`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Point the booking at its train BEFORE the shortage probe — the probe
|
||||
// reads the link to size the need against that schedule's wagons.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
trainScheduleId: target.id,
|
||||
} as never);
|
||||
booking.trainScheduleId = target.id;
|
||||
|
||||
// One dedicated train carries ONE booking: the accept claims the train by
|
||||
// closing its booking window on the spot. Both gates a later booking
|
||||
// passes — the day picker (isStillOpen on windowClosesAt) and the
|
||||
// completion's dedicated-day check — read these fields, so a second
|
||||
// booking can never pick this train.
|
||||
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
|
||||
bookingWindowStatus: "CLOSED",
|
||||
windowClosesAt: new Date(),
|
||||
} as never);
|
||||
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
|
||||
|
||||
const shortage =
|
||||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||||
target.id,
|
||||
booking.id,
|
||||
);
|
||||
if (shortage) {
|
||||
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
|
||||
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
schedulingStatus: "WAITING_FOR_WAGON",
|
||||
} as never);
|
||||
this.logger.warn(
|
||||
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
|
||||
`dedicated train ${target.reference ?? target.id}: needs ` +
|
||||
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
|
||||
);
|
||||
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.allocate(target.id, booking, "shipping_line");
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3535,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
reason: "paid" | "gov",
|
||||
reason: "paid" | "gov" | "shipping_line",
|
||||
): Promise<void> {
|
||||
// Stamp the computed wagon need on the link. Several callers pass a booking
|
||||
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@@ -66,10 +67,16 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
// One resolver for both channels — the company row's own email column is
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr). A
|
||||
// shipping-line booking has no company; its contact is the line's row.
|
||||
const { phone, email } = b.shippingLineCompanyId
|
||||
? await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId,
|
||||
)
|
||||
: b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -90,13 +97,42 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking's portal owner: every portal
|
||||
* user of the company, or — for a shipping-line booking — the line's own
|
||||
* account, deep-linked into the shipping-line app (/shipping-line/*).
|
||||
*/
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (b.shippingLineCompanyId) {
|
||||
void (async () => {
|
||||
const { userId } = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId!,
|
||||
);
|
||||
if (!userId) return;
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title,
|
||||
body,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
// After the spread: the bell must land the line on ITS booking page.
|
||||
link: `/shipping-line/bookings/${b.id}`,
|
||||
});
|
||||
})().catch((err) =>
|
||||
this.logger.warn(
|
||||
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
@@ -209,11 +245,19 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
secured(
|
||||
b: Booking,
|
||||
reason: 'paid' | 'gov' | 'shipping_line',
|
||||
scheduleId?: string | null,
|
||||
): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
reason === 'gov'
|
||||
? ' (government)'
|
||||
: reason === 'shipping_line'
|
||||
? ' (shipping line)'
|
||||
: ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
|
||||
@@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Dedicate this departure to one shipping line. The schedule is then hidden ' +
|
||||
'from every customer-facing read (windows, day pools, home cards) and shown ' +
|
||||
'only to that shipping line in its portal. Omit for a normal customer train.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: CreateScheduleWindowRuleDto,
|
||||
description:
|
||||
|
||||
@@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||
/**
|
||||
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
|
||||
* train whose next cycle would reopen at/after this hour pauses until the next
|
||||
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
|
||||
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk
|
||||
* (the default).
|
||||
*/
|
||||
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
|
||||
@Column({ name: 'window_close_hour', type: 'int', default: 8 })
|
||||
windowCloseHour!: number;
|
||||
|
||||
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity'
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
|
||||
@@ -470,7 +471,11 @@ export class TrainSchedulingService {
|
||||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||
// Dedicated shipping-line departures are never announced to the portal —
|
||||
// the broadcast reaches every customer client.
|
||||
if (fresh && !fresh.shippingLineCompanyId) {
|
||||
this.bookingWindowGateway.emitPhase(fresh);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
@@ -511,7 +516,11 @@ export class TrainSchedulingService {
|
||||
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
})
|
||||
// A dedicated shipping-line departure is never a sibling either: it runs
|
||||
// no window cycle, so it must neither anchor a customer group nor be
|
||||
// dragged through one's open/doc-review/payment instants.
|
||||
.andWhere('s.shippingLineCompanyId IS NULL');
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
@@ -1461,6 +1470,24 @@ export class TrainSchedulingService {
|
||||
|
||||
const scheduleWarnings: string[] = [];
|
||||
|
||||
// Dedicating the departure to a shipping line: the id comes from the
|
||||
// request, so verify it is a real, active line before stamping it.
|
||||
if (dto.shippingLineCompanyId) {
|
||||
const line = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { id: dto.shippingLineCompanyId } });
|
||||
if (!line) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${dto.shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
if (line.status !== 'active') {
|
||||
throw new BadRequestException(
|
||||
`Shipping line ${line.name} is suspended — it cannot be assigned a train`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The pulling set comes either from a built train (Train Builder) or from
|
||||
// hand-picked locomotive ids (legacy path). A built train also links the
|
||||
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
|
||||
@@ -1593,8 +1620,11 @@ export class TrainSchedulingService {
|
||||
// doc-review/payment phase — so there is no cross-expiry to fix, and two
|
||||
// export trains departing the same day at different times must keep their
|
||||
// own departure-anchored windows.
|
||||
// Dedicated shipping-line departures never group either: they run no
|
||||
// window cycle at all, so sharing a customer group's timeline (or
|
||||
// anchoring one) would drag them into phases they must not have.
|
||||
const groupAnchor =
|
||||
direction === 'EXPORT'
|
||||
direction === 'EXPORT' || dto.shippingLineCompanyId
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
manager,
|
||||
@@ -1659,45 +1689,71 @@ export class TrainSchedulingService {
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||||
computedTimes.windowOpensAt = new Date();
|
||||
let windowFields: Partial<TrainSchedule>;
|
||||
if (dto.shippingLineCompanyId) {
|
||||
// Dedicated shipping-line departure: NO window cycle at all. The line
|
||||
// books whenever it wants from creation until the close offset before
|
||||
// departure. windowPhase stays NULL, so the window engine, restamp and
|
||||
// the customer window lists all skip this schedule; the close-offset
|
||||
// gate is enforced by the shipping-line completion path, which reads
|
||||
// windowClosesAt stamped here.
|
||||
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
|
||||
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||
if (closesAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException(
|
||||
'With the booking-close offset applied, this departure would already be ' +
|
||||
'closed for shipping-line booking — pick a later departure.',
|
||||
);
|
||||
}
|
||||
windowFields = {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
windowOpensAt: new Date(),
|
||||
windowClosesAt: closesAt,
|
||||
ruleImportCloseOffsetMinutes: offsetMinutes || null,
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
} else {
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||||
computedTimes.windowOpensAt = new Date();
|
||||
}
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
}
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
// A built train's own consist is the schedule's capacity: full when all
|
||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||
// the configured limit.
|
||||
@@ -1725,6 +1781,7 @@ export class TrainSchedulingService {
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
|
||||
...windowFields,
|
||||
}),
|
||||
);
|
||||
@@ -4474,7 +4531,10 @@ export class TrainSchedulingService {
|
||||
(b) =>
|
||||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||||
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
|
||||
!b.isGovernment,
|
||||
!b.isGovernment &&
|
||||
// Shipping-line bookings pay later on the credit ledger — never PAID
|
||||
// up front, schedulable from accept (FULLY_EXECUTED) like government.
|
||||
!b.shippingLineCompanyId,
|
||||
);
|
||||
if (invalidStatus.length) {
|
||||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||||
@@ -6987,6 +7047,7 @@ export class TrainSchedulingService {
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.shipping_line_company_id IS NULL
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
@@ -7043,6 +7104,7 @@ export class TrainSchedulingService {
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.shipping_line_company_id IS NULL
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
@@ -7152,6 +7214,8 @@ export class TrainSchedulingService {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
// Dedicated shipping-line trains never surface to customer booking.
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||||
@@ -8586,7 +8650,12 @@ export class TrainSchedulingService {
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const eligible = linkedBookings.filter(
|
||||
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
|
||||
(b) =>
|
||||
SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') ||
|
||||
b.isGovernment ||
|
||||
// Shipping-line bookings board without paying up front — their charge
|
||||
// sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable.
|
||||
Boolean(b.shippingLineCompanyId),
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
|
||||
@@ -99,7 +99,9 @@ interface InventoryContext {
|
||||
interface ViewSource {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
companyId: string;
|
||||
/** Nullable on the entity (shipping-line invoices have no company); every
|
||||
* warehouse invoice is customer-billed, so in practice this is always set. */
|
||||
companyId: string | null;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
status: Freight.InvoiceStatus | string;
|
||||
|
||||
@@ -643,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Shipping line booking documents ─────────────────────────────────────────
|
||||
// Collected on a shipping line's booking right after it is initiated. Shipping
|
||||
// lines book without a contract, so this set — not a contract — is what
|
||||
// Operations reviews before the booking may be completed. Fields start empty
|
||||
// and are configured in the backoffice file-settings editor, like the sets
|
||||
// above. `entity: "booking"` puts it alongside the other per-booking sets.
|
||||
const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "shipping_line_booking_documents",
|
||||
label: "Shipping line booking documents",
|
||||
entity: "booking",
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Hazardous cargo documents ───────────────────────────────────────────────
|
||||
// Asked for in the contract wizard the moment the customer flags the cargo as
|
||||
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
|
||||
@@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder {
|
||||
description:
|
||||
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
|
||||
})),
|
||||
...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.",
|
||||
})),
|
||||
];
|
||||
|
||||
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||
|
||||
@@ -499,6 +499,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
// C2. Shipping lines — carriers registered by staff (no self-signup).
|
||||
export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:shipping_lines:view",
|
||||
"View shipping lines",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:shipping_lines:create",
|
||||
"Register shipping line",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:shipping_lines:update",
|
||||
"Update shipping line",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:shipping_lines:reset-password",
|
||||
"Resend shipping line activation link",
|
||||
),
|
||||
];
|
||||
|
||||
// D. Finance — payments + invoices
|
||||
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -552,6 +576,47 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:confirm_offline",
|
||||
"Confirm offline (bank transfer) invoice payment",
|
||||
),
|
||||
// Shipping lines consume services on credit and are invoiced after the fact,
|
||||
// so what they owe is its own Finance surface, separate from invoices:view —
|
||||
// an unbilled credit is not an invoice yet.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:shipping_line_credits:view",
|
||||
"View shipping-line credits and outstanding balance",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:shipping_line_credits:invoice",
|
||||
"Generate an invoice from shipping-line credits",
|
||||
),
|
||||
// Erases a debt outright, which is why it is not folded into :invoice.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:shipping_line_credits:cancel",
|
||||
"Cancel (write off) an unbilled shipping-line credit",
|
||||
),
|
||||
// Two-step manual actions on credit invoices: request grants per action,
|
||||
// decision grants that apply to any pending request.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
"Request marking a shipping-line credit invoice paid (offline payment)",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000005",
|
||||
"edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
"Approve any pending shipping-line credit invoice request",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
"Request cancelling a shipping-line credit invoice",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000007",
|
||||
"edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
"Reject any pending shipping-line credit invoice request",
|
||||
),
|
||||
];
|
||||
|
||||
// E. First / last mile operations
|
||||
@@ -1567,6 +1632,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...REPORT_PERMISSIONS,
|
||||
...CUSTOMER_PERMISSIONS,
|
||||
...SHIPPING_LINE_PERMISSIONS,
|
||||
...FINANCE_PERMISSIONS,
|
||||
...MILE_PERMISSIONS,
|
||||
...FLEET_RAIL_PERMISSIONS,
|
||||
@@ -1773,6 +1839,31 @@ export const FREIGHT_PERMS = {
|
||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:customers:get_notification",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
update: "edr_freight_app:shipping_lines:update",
|
||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||
},
|
||||
shippingLineCredits: {
|
||||
view: "edr_freight_app:shipping_line_credits:view",
|
||||
/** Turn a batch of unbilled credits into an invoice. */
|
||||
invoice: "edr_freight_app:shipping_line_credits:invoice",
|
||||
/** Write off an unbilled credit — separate grant: it erases a debt. */
|
||||
cancel: "edr_freight_app:shipping_line_credits:cancel",
|
||||
// Two-step manual actions on credit invoices, gated purely by permission:
|
||||
// finance-level REQUEST grants (per action) and decision grants that apply
|
||||
// to ANY pending request — including the holder's own.
|
||||
/** Request recording an offline payment against a credit invoice. */
|
||||
invoiceMarkPaid:
|
||||
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
/** Request voiding a credit invoice (credits return to unbilled). */
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
/** Approve any pending invoice request (mark-paid or cancel). */
|
||||
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
/** Reject any pending invoice request. */
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
@@ -2320,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
// Shipping-line credit ledger is a Finance surface: bill batches into
|
||||
// invoices and RAISE manual invoice actions. Approval of those actions is
|
||||
// deliberately absent — it sits with the chief (maker–checker).
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoice,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
],
|
||||
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
|
||||
// the general booking-request list (no bookings:view) — instead a dedicated
|
||||
@@ -2422,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.payments.view,
|
||||
// Decision side of the credit-invoice two-step: finance raises
|
||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
]),
|
||||
// Director additionally manages train scheduling + rail fleet (same block the
|
||||
// operation officer/chief hold), on top of the approval-chain role preset,
|
||||
|
||||
@@ -36,10 +36,14 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
|
||||
import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage";
|
||||
import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config";
|
||||
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AuditLogsPage from "./pages/AuditLogsPage";
|
||||
@@ -222,6 +226,21 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* One drill-down route per overview domain — the old per-tab charts,
|
||||
now each on its own page. Single source of truth for the
|
||||
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
||||
page's "View all" links. */}
|
||||
{OVERVIEW_DOMAINS.map((domain) => (
|
||||
<Route
|
||||
key={domain.key}
|
||||
path={`overview/${domain.key}`}
|
||||
element={
|
||||
<RequirePermission permission={domain.permission}>
|
||||
<OverviewDomainPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="reports"
|
||||
element={
|
||||
@@ -300,6 +319,24 @@ const App = () => {
|
||||
OR'd across both keys so a user with just one still gets in; each
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
<Route
|
||||
path="shipping-lines"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||
<ShippingLineCompaniesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipping-line-credits"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.shippingLineCredits.view}
|
||||
>
|
||||
<ShippingLineCreditsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
||||
import { ExternalLink, MoreHorizontal } from "lucide-react";
|
||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
@@ -64,17 +64,9 @@ export function BookingActionsMenu({
|
||||
|
||||
const hasMenu = listRowHasActions(row, user);
|
||||
|
||||
// Row click already opens the detail page — no chevron affordance needed.
|
||||
if (!hasMenu && variant === "table") {
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||
aria-label="View booking"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Toolbar: lay every action out as a button row.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||
import { humanize } from "@/lib/format";
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
@@ -40,7 +41,7 @@ export function BookingStatusBadge({
|
||||
partnerReference,
|
||||
}: BookingStatusBadgeProps) {
|
||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
label: humanize(status),
|
||||
color: "gray",
|
||||
};
|
||||
const color = statusColorMap[status] ?? "gray";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
// Booking-level flags OR any container line carrying a count — the flag can
|
||||
// lag the lines (per-line opt-ins), so either alone must light the tile.
|
||||
const isHazardous =
|
||||
booking.isHazardous ||
|
||||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
const isReefer =
|
||||
booking.isReefer ||
|
||||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
|
||||
const showHandlingColumns = containers.some(
|
||||
(c) =>
|
||||
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
value={isHazardous ? "Yes" : "No"}
|
||||
highlight={isHazardous}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Refrigerated"
|
||||
value={isReefer ? "Yes" : "No"}
|
||||
highlight={isReefer}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Handling that changes how the yard treats the shipment is flagged
|
||||
loudly, not buried in the grid. */}
|
||||
{(isHazardous || isReefer) && (
|
||||
<Box mt="sm">
|
||||
{isHazardous && (
|
||||
<Badge color="red" variant="filled" radius="sm" mr={8}>
|
||||
Hazardous cargo
|
||||
</Badge>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Badge color="blue" variant="filled" radius="sm">
|
||||
Refrigerated cargo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
<Table.Th>Container type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
|
||||
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
</Table.Td>
|
||||
<Table.Td>{c.quantity}</Table.Td>
|
||||
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.hazardousQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="red" size="sm">
|
||||
{c.hazardousQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.reeferQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="blue" size="sm">
|
||||
{c.reeferQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ActionIcon, Indicator } from "@mantine/core";
|
||||
import { Filter } from "lucide-react";
|
||||
|
||||
export interface FilterToggleProps {
|
||||
/** Number of active advanced filters — shown as a badge on the button. */
|
||||
count: number;
|
||||
expanded: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Toggle for the collapsible advanced-filters row on list pages. */
|
||||
export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) {
|
||||
return (
|
||||
<Indicator
|
||||
label={count}
|
||||
size={16}
|
||||
color="edr-green"
|
||||
disabled={count === 0}
|
||||
offset={4}
|
||||
>
|
||||
<ActionIcon
|
||||
variant={expanded ? "filled" : "default"}
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Toggle advanced filters"
|
||||
aria-expanded={expanded}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Filter size={16} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
@@ -54,10 +55,6 @@ function formatAgo(iso: string): string {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
|
||||
type MilestoneIcon = typeof Send;
|
||||
|
||||
interface Milestone {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
||||
active: <Truck size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
};
|
||||
|
||||
interface ContractStatusTabsProps {
|
||||
active: ContractStatusTabKey;
|
||||
onChange: (tab: ContractStatusTabKey) => void;
|
||||
counts?: Partial<Record<ContractStatusTabKey, number>>;
|
||||
}
|
||||
|
||||
export function ContractStatusTabs({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
}: ContractStatusTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
||||
{CONTRACT_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
leftSection={TAB_ICONS[tab.key]}
|
||||
size={"sm"}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ContractStatusTabKey };
|
||||
@@ -116,8 +116,9 @@ export function CompanyNationalityBadge({
|
||||
|
||||
/**
|
||||
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
||||
* carrying its reference code. Caps at three (a company has at most three
|
||||
* profiles); any extra collapse into a `+N` chip.
|
||||
* carrying its reference code, colored by the profile's status (green active,
|
||||
* amber pending, red rejected/blacklisted). Caps at three (a company has at
|
||||
* most three profiles); any extra collapse into a `+N` chip.
|
||||
*/
|
||||
export function ProfileChips({
|
||||
profiles,
|
||||
@@ -152,14 +153,15 @@ export function ProfileChips({
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
|
||||
color={STATUS_COLOR[profile.status] ?? "gray"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(profile.type)} · {profile.reference}
|
||||
{humanize(profile.type)}
|
||||
{profile.reference ? ` · ${profile.reference}` : ""}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -1,38 +1,2 @@
|
||||
/** Shared formatting helpers for the customer-management pages. */
|
||||
|
||||
/** snake_case / SCREAMING_CASE → Title Case. */
|
||||
export function humanize(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[_\s]+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMoney(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */
|
||||
export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format";
|
||||
|
||||
@@ -36,6 +36,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/bookings",
|
||||
meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/contracts",
|
||||
meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/billing",
|
||||
meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/operations",
|
||||
meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/fleet",
|
||||
meta: { title: "Fleet", subtitle: "Wagon and train fleet status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/customers",
|
||||
meta: { title: "Customers", subtitle: "Customer growth and top accounts" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/staff",
|
||||
meta: { title: "Staff", subtitle: "Employee and user account status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/profile",
|
||||
meta: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
HandCoins,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -70,6 +71,18 @@ export const buildSidebarSections = (
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Lines",
|
||||
href: "/dashboard/shipping-lines",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.shippingLines.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Line Credits",
|
||||
href: "/dashboard/shipping-line-credits",
|
||||
icon: <HandCoins />,
|
||||
permission: FREIGHT_PERMS.shippingLineCredits.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
Box,
|
||||
Clock,
|
||||
Container,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Train,
|
||||
Truck,
|
||||
UserCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewKpis } from "@/types/overview";
|
||||
import { OverviewKpiCard } from "./OverviewKpiCard";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) {
|
||||
const bookingItems = [
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: kpis.bookings.totalActive,
|
||||
icon: FileText,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: kpis.bookings.needsAction,
|
||||
icon: AlertCircle,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: kpis.bookings.urgent,
|
||||
icon: Clock,
|
||||
accent: "rose" as const,
|
||||
},
|
||||
{
|
||||
label: "In approval",
|
||||
value: kpis.bookings.inApproval,
|
||||
icon: UserCheck,
|
||||
accent: "sky" as const,
|
||||
},
|
||||
{
|
||||
label: "Submitted today",
|
||||
value: kpis.bookings.submittedToday,
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const operationsItems = [
|
||||
{
|
||||
label: "Active trains",
|
||||
value: kpis.operations.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: kpis.operations.wagonsAvailable,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Containers in transit",
|
||||
value: kpis.operations.containersInTransit,
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
label: "Cargoes loaded",
|
||||
value: kpis.operations.cargoesLoaded,
|
||||
icon: Box,
|
||||
},
|
||||
];
|
||||
|
||||
const billingItems = [
|
||||
{
|
||||
label: "Revenue MTD (ETB)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"),
|
||||
icon: Banknote,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Revenue MTD (USD)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"),
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
label: "Pending payments",
|
||||
value: kpis.billing.pendingPayments,
|
||||
icon: CreditCard,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Successful MTD",
|
||||
value: kpis.billing.successfulPaymentsMtd,
|
||||
icon: Banknote,
|
||||
},
|
||||
];
|
||||
|
||||
const peopleItems = [
|
||||
{
|
||||
label: "Total customers",
|
||||
value: kpis.customers.totalCustomers,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "New this month",
|
||||
value: kpis.customers.newCustomersThisMonth,
|
||||
icon: Users,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Active employees",
|
||||
value: kpis.staff.activeEmployees,
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
label: "Active users",
|
||||
value: kpis.staff.activeUsers,
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const sections = [
|
||||
{ title: "Bookings", items: bookingItems },
|
||||
{ title: "Operations", items: operationsItems },
|
||||
{ title: "Billing", items: billingItems },
|
||||
{ title: "Customers & staff", items: peopleItems },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => (
|
||||
<Paper
|
||||
key={section.title}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
||||
{section.items.map((item) => (
|
||||
<OverviewKpiCard key={item.label} item={item} />
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
interface OverviewPageHeaderProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewPageHeader({
|
||||
range,
|
||||
onRangeChange,
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: OverviewPageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Paper, Stack, Table, Text } from "@mantine/core";
|
||||
import { History } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Table, Text } from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { IOverviewRecentBooking } from "@/types/overview";
|
||||
import { SummaryCard } from "./summary/SummaryCard";
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null) {
|
||||
if (amount == null) return "—";
|
||||
@@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Recent bookings</Text>
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent bookings
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((booking) => (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.customerLabel}</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<SummaryCard
|
||||
icon={History}
|
||||
accent="gray"
|
||||
title="Recent bookings"
|
||||
subtitle="Latest submissions, newest first"
|
||||
minHeight={260}
|
||||
action={
|
||||
<Text
|
||||
component={Link}
|
||||
to="/dashboard/booking-requests"
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-green"
|
||||
style={{ whiteSpace: "nowrap", textDecoration: "none" }}
|
||||
>
|
||||
View all →
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent bookings
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map(
|
||||
(header) => (
|
||||
<Table.Th
|
||||
key={header}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
color: "var(--mantine-color-edr-muted-6)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</Table.Th>
|
||||
),
|
||||
)}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((booking) => (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" truncate style={{ maxWidth: 180 }}>
|
||||
{booking.customerLabel}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(booking.createdAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
import {
|
||||
useOverviewBillingTab,
|
||||
useOverviewBookingsTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
useOverviewStaffTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
|
||||
function TabSkeleton() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={120} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabContentProps {
|
||||
tab: OverviewTabKey;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||
// Fleet reuses the operations dataset — same query key, so switching between
|
||||
// the two tabs costs one fetch.
|
||||
const operations = useOverviewOperationsTab(
|
||||
range,
|
||||
tab === "operations" || tab === "fleet",
|
||||
);
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||
|
||||
const query =
|
||||
tab === "bookings"
|
||||
? bookings
|
||||
: tab === "contracts"
|
||||
? contracts
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations" || tab === "fleet"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
|
||||
const { isLoading, isError, refetch, isFetching } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return <TabSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !query.data) {
|
||||
return (
|
||||
<Paper p="xl" radius="lg" withBorder>
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to load tab data"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Could not load {tab} metrics. Please try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md" pos="relative">
|
||||
{isFetching && (
|
||||
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{tab === "bookings" && bookings.data && (
|
||||
<OverviewBookingsTabPanel data={bookings.data} />
|
||||
)}
|
||||
{tab === "contracts" && contracts.data && (
|
||||
<OverviewContractsTabPanel data={contracts.data} />
|
||||
)}
|
||||
{tab === "billing" && billing.data && (
|
||||
<OverviewBillingTabPanel data={billing.data} />
|
||||
)}
|
||||
{tab === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "fleet" && operations.data && (
|
||||
<OverviewFleetTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "customers" && customers.data && (
|
||||
<OverviewCustomersTabPanel data={customers.data} />
|
||||
)}
|
||||
{tab === "staff" && staff.data && (
|
||||
<OverviewStaffTabPanel data={staff.data} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* The GL desks (Ethiopia / Djibouti) work cargo through clearance: containers
|
||||
* and cargo state first, the trains carrying them second, and the bookings
|
||||
* waiting on a human third. Clearance-document counts are not aggregated by
|
||||
* the overview API yet, so this stops at cargo state.
|
||||
*/
|
||||
export function ClearanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const handovers = pivotMatrix(clearance.data?.handoversByMile);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Work queue first: it is what a GL desk acts on, and it is the band
|
||||
that always has rows even when no cargo is in the yard. */}
|
||||
<Band index={1} title="Waiting on someone">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Documents & invoices" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
{/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Booking documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contract documents"
|
||||
data={toDonut(tab.contractDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewStackedBarChart
|
||||
title="Handover papers"
|
||||
data={handovers.rows}
|
||||
series={handovers.series}
|
||||
xKey="group"
|
||||
emptyMessage="No handovers generated"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Cargo & containers" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Cargo status"
|
||||
data={toDonut(tab.cargoStatusBreakdown)}
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/**
|
||||
* The default layout — money, attention, network, pipeline. Kept for the CEO,
|
||||
* director and org-manager roles, and for anyone whose role has no dedicated
|
||||
* dashboard (superadmin, IAM admins).
|
||||
*/
|
||||
export function ExecutiveOverview({ data, range }: RoleOverviewProps) {
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { useOverviewBillingTab } from "@/hooks/useOverview";
|
||||
import { AsyncBand, Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/** Money only: what was earned, how it was collected, and what is still owed. */
|
||||
export function FinanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const billing = useOverviewBillingTab(range, true);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Payments" query={billing}>
|
||||
{(tab) => <OverviewBillingTabPanel data={tab} />}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={3} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import { humanize } from "@/lib/format";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
enumLabelsToBars,
|
||||
enumLabelsToDonut,
|
||||
formatDayLabel,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Customer-facing view: who is buying, what they booked, what they signed and
|
||||
* what it earned. Customs-declaration document counts and the Djibouti invoice
|
||||
* queue are part of the marketing brief but have no overview aggregation yet.
|
||||
*/
|
||||
export function MarketingOverview({ data, range }: RoleOverviewProps) {
|
||||
const customers = useOverviewCustomersTab(range, true);
|
||||
const contracts = useOverviewContractsTab(range, true);
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const profilesByType = pivotMatrix(customers.data?.profilesByTypeStatus);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Customers" query={customers}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewCustomersTabPanel data={tab} />
|
||||
{/* Statuses live on the profile, not the company — so active vs
|
||||
pending vs suspended is only meaningful per trade role. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Profiles by trade role and status"
|
||||
data={profilesByType.rows}
|
||||
series={profilesByType.series}
|
||||
xKey="group"
|
||||
formatXLabel={humanize}
|
||||
emptyMessage="No customer profiles yet"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Contracts" query={contracts}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by status"
|
||||
data={toDonut(tab.contractsByStatus)}
|
||||
emptyMessage="No contracts yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by kind"
|
||||
data={enumLabelsToDonut(tab.contractsByKind)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Contracts by freight type"
|
||||
data={enumLabelsToBars(tab.contractsByFreightType)}
|
||||
valueLabel="Contracts"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Documents & invoicing" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Customs documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Invoices by type"
|
||||
data={enumLabelsToBars(tab.invoicesByType)}
|
||||
valueLabel="Invoices"
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={5} title="Revenue & movement">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{ops.data ? (
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={ops.data.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CalendarClock, Send, Timer, Train, Truck } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Operation control centre view: what rolling stock sits where, and what is
|
||||
* moving today. Locomotive availability per station and train turn-around are
|
||||
* part of the OCC brief but have no overview aggregation yet — they are absent
|
||||
* rather than approximated.
|
||||
*/
|
||||
export function OccOverview({ range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const wagonsByYard = pivotMatrix(fleet.data?.wagonStatusByYard);
|
||||
const locomotivesByYard = pivotMatrix(fleet.data?.locomotivesByYard);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Network right now" query={ops}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active trains",
|
||||
value: tab.kpis?.trainsActive ?? 0,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Departures due",
|
||||
value: tab.kpis?.schedulesUpcoming ?? 0,
|
||||
icon: CalendarClock,
|
||||
accent: "sky",
|
||||
},
|
||||
{
|
||||
label: "Dispatched today",
|
||||
value: tab.kpis?.dispatchedToday ?? 0,
|
||||
icon: Send,
|
||||
accent: "amber",
|
||||
},
|
||||
// Labels stay short: five cells share ~1100px at 1440 wide,
|
||||
// and a hint pushes the last two into truncation.
|
||||
{
|
||||
label: "Wagons free",
|
||||
value: tab.kpis?.wagonsAvailable ?? 0,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Turnaround",
|
||||
value:
|
||||
fleet.data?.avgTurnaroundHours != null
|
||||
? `${fleet.data.avgTurnaroundHours}h`
|
||||
: "—",
|
||||
icon: Timer,
|
||||
accent: "violet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Yard and type are long-tailed lists — bars read better than
|
||||
donuts, and the donuts stay at lg 4 so their legends fit. */}
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{/* Per-yard status, not a plain per-yard count: the OCC needs to
|
||||
know which of the wagons standing at a yard can actually run. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by yard and status"
|
||||
data={wagonsByYard.rows}
|
||||
series={wagonsByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons positioned"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by type"
|
||||
data={labelsToBars(tab.wagonsByType)}
|
||||
valueLabel="Wagons"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Wagon status (${sumCounts(tab.wagonStatusBreakdown)})`}
|
||||
data={toDonut(tab.wagonStatusBreakdown)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={2} title="Locomotives" query={fleet}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Locomotives by station and status"
|
||||
data={locomotivesByYard.rows}
|
||||
series={locomotivesByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Locomotive status (${sumCounts(tab.locomotiveStatusBreakdown)})`}
|
||||
data={toDonut(tab.locomotiveStatusBreakdown)}
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Turnaround per train set (departure to departure)"
|
||||
data={(tab.turnaroundByTrain ?? []).map((row) => ({
|
||||
label: row.trainSet,
|
||||
value: row.hours,
|
||||
}))}
|
||||
valueLabel="Hours"
|
||||
emptyMessage="No train set has two recorded departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CircleCheck, Link2, OctagonAlert, Truck, Wrench } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import { TrainLoadCard } from "./TrainLoadCard";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
countByStatus,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Wagon fleet, booking demand and freight moved — the operations officer's
|
||||
* three questions. Wagon counts come from the status breakdown rather than the
|
||||
* headline KPI so every lifecycle state (including detained / out of service)
|
||||
* is accounted for against the same total.
|
||||
*/
|
||||
export function OperationsOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const statusByType = pivotMatrix(fleet.data?.wagonStatusByType);
|
||||
const bookingsByPort = pivotMatrix(ops.data?.bookingStatusByPort);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Wagon fleet" query={ops}>
|
||||
{(tab) => {
|
||||
const wagons = tab.wagonStatusBreakdown ?? [];
|
||||
const total = sumCounts(wagons);
|
||||
const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY");
|
||||
const assigned = countByStatus(wagons, "ASSIGNED");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Five cells fit a 1440px screen only without hints — the
|
||||
status donut below carries the same detail anyway. */}
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{ label: "Total wagons", value: total, icon: Truck },
|
||||
{
|
||||
label: "Available",
|
||||
value: available,
|
||||
icon: CircleCheck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{ label: "Assigned", value: assigned, icon: Link2, accent: "sky" },
|
||||
{
|
||||
label: "Maintenance",
|
||||
value: countByStatus(wagons, "MAINTENANCE"),
|
||||
icon: Wrench,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
label: "Detained / OOS",
|
||||
value: countByStatus(wagons, "DETAINED", "OUT_OF_SERVICE"),
|
||||
icon: OctagonAlert,
|
||||
accent: "rose",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagons by status"
|
||||
data={toDonut(wagons)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{/* Status per type answers "how many flat wagons can I use
|
||||
today", which the plain per-type count could not. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by type and status"
|
||||
data={statusByType.rows}
|
||||
series={statusByType.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by yard"
|
||||
data={labelsToBars(tab.wagonsByYard)}
|
||||
valueLabel="Wagons"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Freight moved" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Trains & ports" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<TrainLoadCard loads={tab.trainLoads ?? []} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Bookings by port and status"
|
||||
data={bookingsByPort.rows}
|
||||
series={bookingsByPort.series}
|
||||
xKey="group"
|
||||
emptyMessage="No bookings in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TrainFront } from "lucide-react";
|
||||
import { Badge, Group, Progress, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import type { IOverviewTrainLoad } from "@/types/overview";
|
||||
import { SummaryCard } from "@/components/overview/summary/SummaryCard";
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "yellow",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/**
|
||||
* Wagon fill per scheduled train: the bar is allocated slots against the train
|
||||
* set's own wagon count, so a short train at 100% reads as full rather than as
|
||||
* a small number next to a big one.
|
||||
*/
|
||||
export function TrainLoadCard({ loads = [] }: { loads: IOverviewTrainLoad[] }) {
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={TrainFront}
|
||||
accent="blue"
|
||||
title="Wagons allocated per train"
|
||||
subtitle="Slots filled and tonnage loaded"
|
||||
>
|
||||
{loads.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No scheduled trains
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{loads.map((load) => {
|
||||
const percent = load.wagonsTotal
|
||||
? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<Stack key={load.scheduleId} gap={4}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{load.trainNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={DIRECTION_COLOR[load.direction] ?? "gray"}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
{humanize(load.direction)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{load.date}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "}
|
||||
{Math.round(load.tons).toLocaleString()} t
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
color={percent >= 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Skeleton, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||
import type {
|
||||
IOverviewDashboard,
|
||||
IOverviewLabelCount,
|
||||
IOverviewMatrixCell,
|
||||
IOverviewStatusCount,
|
||||
OverviewRange,
|
||||
} from "@/types/overview";
|
||||
|
||||
/** Every role layout takes the same summary payload + the selected range. */
|
||||
export interface RoleOverviewProps {
|
||||
data: IOverviewDashboard;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
export function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
export function Band({
|
||||
index,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal shape of the react-query result a band consumes. */
|
||||
interface BandQuery<T> {
|
||||
data?: T;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A band fed by one of the per-domain overview endpoints. Loading shows a
|
||||
* skeleton, a failure degrades to an inline notice — a role dashboard stitches
|
||||
* several endpoints together and one 403 (a role without that domain's
|
||||
* permission) must not blank the whole page.
|
||||
*/
|
||||
export function AsyncBand<T>({
|
||||
index,
|
||||
title,
|
||||
query,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
query: BandQuery<T>;
|
||||
children: (data: T) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Band index={index} title={title}>
|
||||
{query.isLoading ? (
|
||||
<Skeleton height={300} radius="lg" />
|
||||
) : query.isError || !query.data ? (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
title={`${title} unavailable`}
|
||||
>
|
||||
This section could not be loaded for your account.
|
||||
</Alert>
|
||||
) : (
|
||||
children(query.data)
|
||||
)}
|
||||
</Band>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
||||
export const DIRECTION_SERIES = [
|
||||
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
||||
{ key: "importCount", label: "Import", color: "#0369a1" },
|
||||
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
||||
];
|
||||
|
||||
export function formatDayLabel(date: string) {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function sumCounts(items: IOverviewStatusCount[] = []) {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) {
|
||||
return items
|
||||
.filter((item) => statuses.includes(item.status))
|
||||
.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */
|
||||
export function toDonut(items: IOverviewStatusCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.status), value: item.count }));
|
||||
}
|
||||
|
||||
/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */
|
||||
export function labelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
export function labelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix cells → the row/series shape OverviewStackedBarChart wants: one row
|
||||
* per `group`, one series per distinct `series` value, colors fixed by the
|
||||
* series' position so a status keeps its color across charts.
|
||||
*/
|
||||
export function pivotMatrix(cells: IOverviewMatrixCell[] = []) {
|
||||
const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort();
|
||||
const groups = [...new Set(cells.map((cell) => cell.group))];
|
||||
|
||||
const rows = groups.map((group) => {
|
||||
const row: Record<string, string | number> = { group };
|
||||
for (const key of seriesKeys) row[key] = 0;
|
||||
for (const cell of cells) {
|
||||
if (cell.group === group) row[cell.series] = cell.count;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const series = seriesKeys.map((key, index) => ({
|
||||
key,
|
||||
label: humanize(key),
|
||||
color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length],
|
||||
}));
|
||||
|
||||
return { rows, series };
|
||||
}
|
||||
|
||||
/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */
|
||||
export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.label), value: item.count }));
|
||||
}
|
||||
|
||||
export function enumLabelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: humanize(item.label), value: item.count }));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Banknote,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
TrainFront,
|
||||
UserCheck,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import type { OverviewTabKey } from "@/types/overview";
|
||||
|
||||
/**
|
||||
* Single source of truth for the seven overview drill-down pages — used both
|
||||
* to build the `/dashboard/overview/:domain` routes in App.tsx and to render
|
||||
* each page's header in OverviewDomainPage. One list, no duplicated permission
|
||||
* arrays to drift out of sync.
|
||||
*/
|
||||
export const OVERVIEW_DOMAINS: Array<{
|
||||
key: OverviewTabKey;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
icon: LucideIcon;
|
||||
/** Any of these keys grants the page. */
|
||||
permission: string[];
|
||||
}> = [
|
||||
{
|
||||
key: "bookings",
|
||||
label: "Bookings",
|
||||
subtitle: "Booking volume, pipeline, and recent activity",
|
||||
icon: FileText,
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
key: "contracts",
|
||||
label: "Contracts",
|
||||
subtitle: "Contract volume, pipeline, and recent activity",
|
||||
icon: FileSignature,
|
||||
permission: [FREIGHT_PERMS.contracts.view],
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
subtitle: "Revenue, payments, and collection status",
|
||||
icon: Banknote,
|
||||
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
subtitle: "Trains, schedules, containers, and cargo",
|
||||
icon: Train,
|
||||
permission: [
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fleet",
|
||||
label: "Fleet",
|
||||
subtitle: "Wagon and train fleet status",
|
||||
icon: TrainFront,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
label: "Customers",
|
||||
subtitle: "Customer growth and top accounts",
|
||||
icon: Users,
|
||||
permission: [FREIGHT_PERMS.customers.view],
|
||||
},
|
||||
{
|
||||
key: "staff",
|
||||
label: "Staff",
|
||||
subtitle: "Employee and user account status",
|
||||
icon: UserCheck,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,8 @@
|
||||
/* ============================================================
|
||||
EDR Freight — Overview page styles (hero controls + tabs)
|
||||
EDR Freight — shared "premium" tab bar + segmented control styles.
|
||||
Named after the overview page they were first built for, but now shared
|
||||
by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal —
|
||||
do not remove `.ov-tablist` / `.ov-tab` without checking those importers.
|
||||
============================================================ */
|
||||
|
||||
/* ---- Hero range segmented control (on gradient) ---- */
|
||||
@@ -19,7 +22,7 @@
|
||||
color: var(--mantine-color-edr-green-7);
|
||||
}
|
||||
|
||||
/* ---- Premium tab bar ---- */
|
||||
/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */
|
||||
.ov-tablist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user