Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/report

This commit is contained in:
Roba Boru
2026-08-13 22:48:00 +03:00
206 changed files with 19403 additions and 1614 deletions

View File

@@ -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,

View File

@@ -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`,
);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Makerchecker 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`,
);
}
}

View File

@@ -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"],

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
);
}
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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 })

View File

@@ -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")

View File

@@ -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: [

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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 = 0003 … 7 = 2124' })
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;
}

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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 = 0003 … 7 = 2124) — 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
`);
}
}

View File

@@ -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(),
};
}

View File

@@ -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])

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}%` },
);
}

View File

@@ -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,

View File

@@ -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');
});
});

View File

@@ -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.
*

View File

@@ -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,
);
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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",
}
/**
* Makerchecker 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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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")}`;
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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));
}
}

View File

@@ -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 }),
);
});
});

View File

@@ -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 };
}
}

View File

@@ -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);
}
// ── Makerchecker 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);
}
}

View File

@@ -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,
});
}
}

View File

@@ -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 makerchecker 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);
});
});
});

View File

@@ -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 + makerchecker 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;
}
}

View File

@@ -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" },
});
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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:

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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 (makerchecker).
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,

View File

@@ -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={

View File

@@ -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.

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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>
))}

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }));
}

View File

@@ -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,
],
},
];

View File

@@ -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