mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
Merge pull request #1279 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -40,6 +40,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
|
||||
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
@@ -201,6 +203,8 @@ if (!process.env.APPLICATION_NAME) {
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CompaniesModule,
|
||||
ShippingLineCompaniesModule,
|
||||
ShippingLineBookingCompletionModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Shipping lines — carriers registered by backoffice staff who sign in to the
|
||||
* portal directly.
|
||||
*
|
||||
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
|
||||
* business licence, eTrade record, operational profile or onboarding state, so
|
||||
* it shares none of the customer columns. `user_id` sits on the company row
|
||||
* itself because the company IS the account — there is no contact-person row.
|
||||
*
|
||||
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
|
||||
* this API reads but never owns.
|
||||
*/
|
||||
export class ShippingLineCompany3440000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_companies_status_enum
|
||||
AS ENUM ('active', 'suspended');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
name varchar(200) NOT NULL,
|
||||
scac_code varchar(4),
|
||||
imo_number varchar(20),
|
||||
bic_code varchar(20),
|
||||
email varchar(150) NOT NULL,
|
||||
phone_number varchar(30),
|
||||
status freight.shipping_line_companies_status_enum
|
||||
NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// One login per shipping line. Partial so a soft-deleted row frees its
|
||||
// account for re-registration rather than blocking it forever.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
|
||||
ON freight.shipping_line_companies (user_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// SCAC identifies the carrier globally — two live lines cannot share one.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
|
||||
ON freight.shipping_line_companies (scac_code)
|
||||
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
|
||||
ON freight.shipping_line_companies (lower(email))
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
|
||||
ON freight.shipping_line_companies (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines book rail capacity directly, without a contract.
|
||||
*
|
||||
* A booking has always been owned by `company_id` (a customer `companies` row),
|
||||
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
|
||||
* company — it carries no TIN, licence or operational profiles. So it gets its
|
||||
* own nullable owner column rather than a synthetic company row.
|
||||
*
|
||||
* Exactly one of the two is set: `company_id` for a customer booking,
|
||||
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
|
||||
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
|
||||
* backfilling and every customer query filtering on `company_id` behaves
|
||||
* exactly as before. Government bookings already bill to a seeded government
|
||||
* company, so they satisfy the CHECK unchanged.
|
||||
*
|
||||
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
|
||||
* is cargo metadata naming the carrier line that moves the goods
|
||||
* (`freight.shipping_lines`, reference data). This column points at
|
||||
* `freight.shipping_line_companies` — the portal account — and is unrelated.
|
||||
*/
|
||||
export class BookingShippingLine3450000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
|
||||
ON freight.bookings (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
|
||||
// tables, so a shipping-line booking could not be inserted at all. Relax
|
||||
// them to nullable; their foreign keys are left in place and keep validating
|
||||
// every non-NULL value, so a customer booking is constrained exactly as
|
||||
// before. The CHECK below is what now guarantees an owner is present.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Route and service are inherited from the contract on a customer booking.
|
||||
// A shipping line initiates before any of that is known — the bare booking
|
||||
// exists only to hang documents off — so these are relaxed too and filled
|
||||
// in when the booking is completed. Existing rows all have values, and the
|
||||
// customer paths still always set them.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
|
||||
`);
|
||||
|
||||
// No FK: kept consistent with how the column is populated at the service
|
||||
// layer, and avoids a lock on shipping_line_companies during deploy.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_single_owner
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.bookings
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
for (const column of [
|
||||
"company_id",
|
||||
"company_profile_id",
|
||||
"origin_yard_id",
|
||||
"destination_yard_id",
|
||||
"service_type_id",
|
||||
"freight_type",
|
||||
]) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines consume services before paying for them.
|
||||
*
|
||||
* A shipping line books rail capacity and the booking proceeds with no payment
|
||||
* gate at all — unlike a customer booking, which cannot advance until its
|
||||
* PREPAID invoice settles. What the line owes is instead recorded here as a
|
||||
* credit: one row per booking, priced once and never recalculated. Finance
|
||||
* later selects a batch of unbilled credits, generates a single invoice for
|
||||
* them, and the line pays that invoice through the normal CBE flow. When the
|
||||
* invoice settles, its credits are marked paid and stop counting as debt.
|
||||
*
|
||||
* This is deliberately NOT a wallet or a stored balance. There is no money in
|
||||
* the system to draw down: a credit is a debt the line already incurred, so
|
||||
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
|
||||
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
|
||||
*
|
||||
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
|
||||
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
|
||||
* shipping line is not a `companies` row and never will be, so an invoice
|
||||
* billed to one has no customer to point at. Both FKs stay in place and keep
|
||||
* validating every non-NULL value, so a customer invoice is constrained
|
||||
* exactly as before; the CHECK below is what now guarantees a payer exists.
|
||||
*/
|
||||
export class ShippingLineCredits3460000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Invoices: allow a shipping-line payer ────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
|
||||
ON freight.invoices (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
|
||||
// answer "who owes this?" the same way. Existing rows all have company_id
|
||||
// and a NULL shipping_line_company_id, so nothing needs backfilling.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD CONSTRAINT chk_invoices_single_payer
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── The credit ledger ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
|
||||
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
shipping_line_company_id uuid NOT NULL,
|
||||
booking_id uuid NOT NULL,
|
||||
amount numeric(14,2) NOT NULL,
|
||||
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
|
||||
status freight.shipping_line_credits_status_enum
|
||||
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
|
||||
description character varying(255),
|
||||
invoice_id uuid,
|
||||
billed_at timestamp with time zone,
|
||||
paid_at timestamp with time zone,
|
||||
cancelled_at timestamp with time zone,
|
||||
cancellation_reason character varying(255),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
|
||||
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
|
||||
-- The state machine, enforced in the DB rather than trusted to the
|
||||
-- service: an UNBILLED credit has no invoice, and anything past
|
||||
-- UNBILLED must name the invoice it was billed on. Without this a
|
||||
-- half-applied batch could leave BILLED rows with a NULL invoice_id
|
||||
-- and silently vanish from both the unbilled list and the invoice.
|
||||
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
|
||||
(status = 'UNBILLED' AND invoice_id IS NULL)
|
||||
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
|
||||
OR status = 'CANCELLED'
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_booking
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// SET NULL rather than CASCADE: deleting an invoice must never delete the
|
||||
// record of what was owed. The row would then violate the link CHECK, so a
|
||||
// credit whose invoice is removed has to be walked back to UNBILLED
|
||||
// explicitly — which is the correct, visible outcome.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_invoice
|
||||
FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices(id) ON DELETE SET NULL
|
||||
`);
|
||||
|
||||
// One live credit per booking. Partial so a soft-deleted or cancelled row
|
||||
// does not block re-pricing a booking that was voided and rebooked.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
|
||||
ON freight.shipping_line_credits (booking_id)
|
||||
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
|
||||
`);
|
||||
|
||||
// Drives the two hot reads: finance's unbilled worklist per line, and the
|
||||
// outstanding total on the shipping-line detail page.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
|
||||
ON freight.shipping_line_credits (shipping_line_company_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
|
||||
ON freight.shipping_line_credits (invoice_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.shipping_line_credits
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.invoices
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-shipping-line rates.
|
||||
*
|
||||
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
|
||||
* and negotiates its own prices, so the rate table gains an owner column:
|
||||
* `shipping_line_company_id` NULL = the standard rate every customer pays,
|
||||
* NOT NULL = a rate that only that line's bookings resolve.
|
||||
*
|
||||
* Points at `freight.shipping_line_companies` (the portal account that owns the
|
||||
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
|
||||
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
|
||||
* already keys off it. Both stay independent.
|
||||
*
|
||||
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
|
||||
* that line's rate for the lane, and is hard-blocked when none exists (the
|
||||
* standard rate is deliberately not a fallback — see RuleEngineService).
|
||||
*
|
||||
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
|
||||
* standard-rate lookups behave exactly as before.
|
||||
*/
|
||||
export class ShippingLineRates3470000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_shipping_line_company"
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// Rate resolution always filters by owner, so the lookups this column
|
||||
// participates in are (owner, lane) — indexed together with rate_type,
|
||||
// which every lookup also pins.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
|
||||
ON freight.rates (shipping_line_company_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
|
||||
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
|
||||
// A shipping line sells import freight only — the export leg is contracted
|
||||
// through the customer, not the carrier. Enforced here so a line rate can
|
||||
// never be filed against an export lane regardless of which API path wrote
|
||||
// it. Surcharges carry no direction and are unaffected.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
shipping_line_company_id IS NULL OR
|
||||
trade_direction IS NULL OR trade_direction = 'IMPORT'
|
||||
)
|
||||
`);
|
||||
|
||||
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
|
||||
// rate collides with the standard rate for the same lane — same rate_type,
|
||||
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
|
||||
// (the standard rate) collapses to the zero uuid like every other nullable
|
||||
// scope column, so existing rows keep their current uniqueness exactly.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restore the pre-owner pattern index (as left by LastMileRateBands).
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A train schedule can be dedicated to one shipping line.
|
||||
*
|
||||
* NULL = a normal train, visible and bookable to customers as before. Set =
|
||||
* the departure exists for that shipping line alone: it is excluded from every
|
||||
* customer-facing read (booking windows, day pools, portal home cards) and
|
||||
* surfaces only in the assigned line's portal (home page + booking detail).
|
||||
*/
|
||||
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
|
||||
ON freight.train_schedules (shipping_line_company_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Default the daily booking desk to 24 hours: window_close_hour equal to
|
||||
* window_open_hour means the desk never pauses overnight. Aligns the column
|
||||
* default and the existing global-rules row; per-schedule overrides keep
|
||||
* whatever staff set on them.
|
||||
*/
|
||||
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 8
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = window_open_hour
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 17
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = 17
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
|
||||
* manual paths exist for finance: recording an offline payment (MARK_PAID)
|
||||
* and voiding an invoice raised in error (CANCEL, which releases its credits
|
||||
* back to the unbilled pool). Both erase or move real debt, so neither is a
|
||||
* single-person action: one permission raises the request, a different
|
||||
* permission — held by a chief, and never the requester themselves — approves
|
||||
* or rejects it. Rows are never deleted; decided requests are the audit trail.
|
||||
*
|
||||
* One PENDING row per invoice at a time (partial unique index): a second
|
||||
* request while one is undecided is a coordination failure, not a workflow.
|
||||
*/
|
||||
export class ShippingLineInvoiceApprovals3530000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
|
||||
AS ENUM ('MARK_PAID', 'CANCEL');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
|
||||
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
|
||||
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
requested_by uuid NOT NULL,
|
||||
reason varchar(500) NOT NULL,
|
||||
payment_reference varchar(255),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id, status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per invoice.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
||||
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
||||
|
||||
// Shipping Line
|
||||
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
|
||||
// not an account)
|
||||
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||
|
||||
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||
|
||||
// Signature
|
||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
@@ -86,7 +87,66 @@ export class CustomerResetService {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
return this.sendResetLinkToUser(resolved.userId, channel, {
|
||||
scope: `company ${companyId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and deliver a reset link to a specific IAM account.
|
||||
*
|
||||
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
||||
* that resolve their target differently can reuse it: a customer is found via
|
||||
* the company's primary contact, while a shipping line has no contact row at
|
||||
* all and resolves straight off its own record. Everything below the lookup —
|
||||
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
||||
* undelivered-link diagnostic — is identical for both and must stay that way.
|
||||
*
|
||||
* `scope` only labels the log line with whatever the caller resolved from.
|
||||
*
|
||||
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
||||
* the default gate requires an existing active credential (so a reset cannot
|
||||
* revive a suspended account), but an account that has never set a password
|
||||
* has no credential row yet and would be excluded from its own activation
|
||||
* link. Callers pass it only when the account is expected to be
|
||||
* password-less — see ShippingLineCompaniesService.
|
||||
*/
|
||||
async sendResetLinkToUser(
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): Promise<SentResetLink | null> {
|
||||
const user = options?.allowWithoutCredential
|
||||
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`User ${userId} is not an active account${
|
||||
options?.allowWithoutCredential
|
||||
? ""
|
||||
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.deliverResetLink(user, user.id, channel, options?.scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail: target selection → SMS reachability → mint → send → report.
|
||||
* Callers have already resolved `user` to an active account.
|
||||
*/
|
||||
private async deliverResetLink(
|
||||
user: User,
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
scope?: string,
|
||||
): Promise<SentResetLink | null> {
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${"link"}`,
|
||||
);
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
@@ -110,6 +170,9 @@ export class CustomerResetService {
|
||||
);
|
||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${link}`,
|
||||
);
|
||||
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
@@ -127,7 +190,22 @@ export class CustomerResetService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} queued=${queued}`,
|
||||
);
|
||||
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
||||
// read access to the log stream can set the password for the account named
|
||||
// on the same line — including on sends that succeeded, not just failures.
|
||||
// Kept deliberately: log aggregation is the debugging path for flaky
|
||||
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
||||
// that is ever revisited, gate this on an env flag rather than deleting it,
|
||||
// so dev keeps its workflow.
|
||||
this.logger.warn(
|
||||
`reset-link.cleartext channel=${channel} user=${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} link=${link}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
|
||||
@@ -89,6 +89,28 @@ export class ForgotPasswordService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active account by id, WITHOUT requiring an existing credential.
|
||||
*
|
||||
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
|
||||
* is right for a *reset*: it stops a staff-triggered link from reactivating a
|
||||
* suspended account. But an account that has never set a password has no
|
||||
* credential row yet, so that join excludes exactly the accounts a first-time
|
||||
* *activation* link is for — shipping lines are created deliberately without
|
||||
* one (see ShippingLineCompaniesService.register).
|
||||
*
|
||||
* The `isActive` gate is kept; only the credential requirement is dropped.
|
||||
*/
|
||||
async resolveActivatableUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.where("u.isActive = true")
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.orderBy("u.createdAt", "DESC")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
|
||||
"This password-reset link is invalid or has expired. Request a new one.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
// Credential-less on purpose: this resolves links for *setting* a password,
|
||||
// which includes first-time activation of an account that has never had one
|
||||
// (shipping lines are created without a credential row). Requiring one here
|
||||
// rejected a perfectly valid activation link before its token was ever
|
||||
// checked. The ticket checks below are what actually authorise the reset.
|
||||
const user = await this.resolveActivatableUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !identifier) throw invalid;
|
||||
if (!user || !identifier) {
|
||||
// Logged because the early return above bypasses the rejection warning
|
||||
// below — without this, an account that fails the lookup produces no
|
||||
// diagnostic at all and looks identical to a bad token.
|
||||
this.logger.warn(
|
||||
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
|
||||
);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
const verification = await this.dataSource
|
||||
.getRepository(UserVerification)
|
||||
|
||||
@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
exports: [CustomerResetService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
// Cycles back via ShippingLineCompaniesModule, which imports this module.
|
||||
forwardRef(() => CompaniesModule),
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
@@ -114,8 +117,16 @@ export interface GenerateInvoiceInput {
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** The customer billed. Omit only when billing a shipping line instead. */
|
||||
companyId?: string | null;
|
||||
companyProfileId?: string | null;
|
||||
/**
|
||||
* The shipping line billed, for an invoice covering batched shipping-line
|
||||
* credits. Mutually exclusive with `companyId` — the DB enforces this via
|
||||
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
|
||||
* setting both or neither before it ever reaches the constraint.
|
||||
*/
|
||||
shippingLineCompanyId?: string | null;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
@@ -141,8 +152,11 @@ export interface InvoiceEventPayload {
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** Null when the payer is a shipping line rather than a customer company. */
|
||||
companyId: string | null;
|
||||
companyProfileId: string | null;
|
||||
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
@@ -563,23 +577,61 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
/**
|
||||
* Resolve a shipping-line company from the signed-in user (null for ordinary
|
||||
* customers). Queried straight off the entity rather than through
|
||||
* ShippingLineCompaniesService — that module already imports billing, so a
|
||||
* service edge back would deepen the forwardRef cycle for one lookup.
|
||||
*/
|
||||
private async resolveShippingLineCompanyId(
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
const line = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { userId } });
|
||||
return line?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices for the signed-in portal user; empty when they have no company.
|
||||
* A payer is either a customer company or a shipping line (enforced by the
|
||||
* DB's single-payer check), so the two lookups cannot both match.
|
||||
*/
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
if (companyId) return this.findByCompany(companyId, filter);
|
||||
|
||||
const shippingLineCompanyId =
|
||||
await this.resolveShippingLineCompanyId(userId);
|
||||
if (!shippingLineCompanyId) return [];
|
||||
return this.invoices.findAll({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
const ownedByCompany =
|
||||
invoice.companyId != null &&
|
||||
invoice.companyId === (await this.resolveCompanyId(userId));
|
||||
const ownedByShippingLine =
|
||||
!ownedByCompany &&
|
||||
invoice.shippingLineCompanyId != null &&
|
||||
invoice.shippingLineCompanyId ===
|
||||
(await this.resolveShippingLineCompanyId(userId));
|
||||
if (!ownedByCompany && !ownedByShippingLine) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
@@ -669,7 +721,6 @@ export class BillingService {
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
console.log("oooooooooo", input);
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
@@ -682,6 +733,21 @@ export class BillingService {
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
// Exactly one payer, checked here so a bad payload fails with a clear
|
||||
// message instead of a raw `chk_invoices_single_payer` violation.
|
||||
const billsCompany = Boolean(input.companyId);
|
||||
const billsShippingLine = Boolean(input.shippingLineCompanyId);
|
||||
if (billsCompany === billsShippingLine) {
|
||||
throw new BadRequestException(
|
||||
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
|
||||
);
|
||||
}
|
||||
if (billsCompany && !input.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
"companyProfileId is required when billing a company.",
|
||||
);
|
||||
}
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
@@ -717,8 +783,9 @@ export class BillingService {
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
companyId: input.companyId ?? null,
|
||||
companyProfileId: input.companyProfileId ?? null,
|
||||
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
@@ -1048,6 +1115,7 @@ export class BillingService {
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
|
||||
@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
|
||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
/** The customer (company) this invoice is billed to. */
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
/**
|
||||
* The customer (company) this invoice is billed to. Null on a shipping-line
|
||||
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
|
||||
* line is deliberately not a `companies` row. A DB CHECK
|
||||
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
|
||||
*/
|
||||
@Column({ name: "company_id", type: "uuid", nullable: true })
|
||||
companyId!: string | null;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||
@Column({ name: "company_profile_id", type: "uuid" })
|
||||
companyProfileId!: string;
|
||||
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
|
||||
companyProfileId!: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile)
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
/**
|
||||
* The shipping line billed, when this invoice bills batched shipping-line
|
||||
* credits rather than a customer booking. Mutually exclusive with
|
||||
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
|
||||
* that edge, and importing the shipping-lines module here would close an
|
||||
* import cycle (shipping-lines already depends on billing).
|
||||
*/
|
||||
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
|
||||
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
subtotalAmount!: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
|
||||
// Both channels come from the same resolver: the company row's own columns
|
||||
// are only half the story (see companyNotifyEmailExpr), and reading them off
|
||||
// the loaded entity silently dropped every mail to a company whose address
|
||||
// lives in `attributes`.
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
// lives in `attributes`. A shipping-line booking has NO company — its
|
||||
// contact lives on the shipping_line_companies row itself.
|
||||
const { phone, email } = b.shippingLineCompanyId
|
||||
? await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId,
|
||||
)
|
||||
: b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking's portal owner: every portal
|
||||
* user of the company, or — for a shipping-line booking — the line's own
|
||||
* account, deep-linked into the shipping-line app rather than the customer
|
||||
* one (its routes live under /shipping-line/*).
|
||||
*/
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (b.shippingLineCompanyId) {
|
||||
void (async () => {
|
||||
const { userId } = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId!,
|
||||
);
|
||||
if (!userId) return;
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
// After the spread: overrides carry customer links — the bell must
|
||||
// land a shipping line on ITS booking page.
|
||||
link: `/shipping-line/bookings/${b.id}`,
|
||||
});
|
||||
})().catch((err) =>
|
||||
this.logger.warn(
|
||||
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
|
||||
|
||||
/** Document approval finalized → customer can proceed to request operation. */
|
||||
clearanceReady(b: Booking): void {
|
||||
const msg =
|
||||
`Document approval for booking ${b.reference} is finalized. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
// A shipping line's next move is BOOKING (cargo + shipment day), not the
|
||||
// customer's operation-request step — say so, or the message points at a
|
||||
// flow their portal does not have.
|
||||
const msg = b.shippingLineCompanyId
|
||||
? `Documents for booking ${b.reference} are approved. ` +
|
||||
`You can now book your shipment — enter the cargo and shipment day from the portal.`
|
||||
: `Document approval for booking ${b.reference} is finalized. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
|
||||
this.inApp(b, 'Document approval finalized', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
});
|
||||
this.inApp(
|
||||
b,
|
||||
b.shippingLineCompanyId
|
||||
? 'Documents approved — book your shipment'
|
||||
: 'Document approval finalized',
|
||||
msg,
|
||||
{ type: NotificationType.CLEARANCE_DECISION },
|
||||
);
|
||||
}
|
||||
|
||||
/** Intercity documents approved → booking waits in the ride-along pool. */
|
||||
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
|
||||
|
||||
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||
operationAccepted(b: Booking): void {
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} has been accepted. ` +
|
||||
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||
// No invoice and no pay window for a shipping line — the charge sits on
|
||||
// its credit account and the booking boards its dedicated train directly.
|
||||
const msg = b.shippingLineCompanyId
|
||||
? `Your booking ${b.reference} has been accepted. The charge has been ` +
|
||||
`recorded on your credit account and your shipment is being placed on its train.`
|
||||
: `Your operation request for booking ${b.reference} has been accepted. ` +
|
||||
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
|
||||
this.inApp(b, 'Operation request accepted', msg);
|
||||
this.inApp(
|
||||
b,
|
||||
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,7 +165,7 @@ export class BookingPricingService {
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||||
|
||||
@@ -414,6 +414,9 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
// A shipping line's own booking prices off that line's negotiated rates
|
||||
// instead of the standard customer ones (see RuleEngineService.ratesForOwner).
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
@@ -428,6 +431,22 @@ export class BookingPricingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE rates this booking may price off.
|
||||
*
|
||||
* A shipping-line booking sees only its own line's rates; a customer booking
|
||||
* only the standard ones. Line rates override rather than stack, and the
|
||||
* standard rate is not a fallback — a lane the line has no rate for falls
|
||||
* through to the existing "no rate configured" hard block, which is the
|
||||
* intended outcome rather than silently billing the customer price.
|
||||
*/
|
||||
private async liveRatesForBooking(booking: Booking): Promise<Rate[]> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
return booking.shippingLineCompanyId
|
||||
? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId)
|
||||
: rates.filter((r) => !r.shippingLineCompanyId);
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
@@ -512,7 +531,7 @@ export class BookingPricingService {
|
||||
warnings: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
@@ -713,7 +732,7 @@ export class BookingPricingService {
|
||||
return { lineItems: [], usedRates: [] };
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
|
||||
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
@@ -944,6 +946,15 @@ export class BookingTransitionService {
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
requestedTrainScheduleId?: string | null,
|
||||
opts?: {
|
||||
/**
|
||||
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
|
||||
* the shipping-line completion path, which has already validated the day
|
||||
* against the line's own dedicated train (those trains are excluded from
|
||||
* the customer pools, so the gate here would wrongly reject them).
|
||||
*/
|
||||
bypassDayPool?: boolean;
|
||||
},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -971,20 +982,22 @@ export class BookingTransitionService {
|
||||
// gate; quantity never blocks — oversized bookings get a partial split
|
||||
// offer). The batch engine assigns the specific train within that
|
||||
// (route, day) pool later.
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
if (!opts?.bypassDayPool) {
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export is FCFS and never splits — a booking must ride one train whole. So
|
||||
@@ -1001,7 +1014,14 @@ export class BookingTransitionService {
|
||||
// The customer's train pick only exists for export rail; it rides the
|
||||
// booking through the space checks below AND is persisted so the accept /
|
||||
// reserve path locks onto that train (pickExportSchedule honors it).
|
||||
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
|
||||
// Shipping-line completions (bypassDayPool) pick among the line's own
|
||||
// dedicated trains — already validated by the caller, so the pick is
|
||||
// persisted here the same way an export pick is. Customer import/domestic
|
||||
// bookings still never carry one (the batch engine assigns their train).
|
||||
const requestedId =
|
||||
isExportTrain || opts?.bypassDayPool
|
||||
? (requestedTrainScheduleId ?? null)
|
||||
: null;
|
||||
// Export rail rides the exact train the customer picked — never an
|
||||
// auto-assigned one. Both portal flows (clearance + contract completion)
|
||||
// surface a picker, so a missing id is an invalid submission, not a
|
||||
@@ -1205,10 +1225,22 @@ export class BookingTransitionService {
|
||||
// booking page correctly still showed it as not payable. The batch engine
|
||||
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
||||
// and the real deadline are created — matching the portal's `canPay` gate.
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
//
|
||||
// Shipping-line bookings mint NO invoice at all: they have no company row
|
||||
// to bill (the invoices FK requires one) and they pay on the credit ledger
|
||||
// — the charge was recorded at completion, and Finance bills a batch of
|
||||
// credits later through ShippingLineCreditsService.generateInvoice.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
this.logger.log(
|
||||
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
|
||||
);
|
||||
} else {
|
||||
const invoice =
|
||||
await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
}
|
||||
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
||||
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
||||
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
||||
@@ -1222,6 +1254,7 @@ export class BookingTransitionService {
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
const roadFresh = await this.bookingsService.findById(booking.id);
|
||||
this.emitShippingLineAccepted(roadFresh);
|
||||
this.notifier.operationAccepted(roadFresh);
|
||||
return roadFresh;
|
||||
}
|
||||
@@ -1258,11 +1291,44 @@ export class BookingTransitionService {
|
||||
// batch runs after the window closes + staff document review, never at accept
|
||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||
// by the periodic legacy fill.)
|
||||
//
|
||||
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
|
||||
// no pay window exists to wait for — accept places them straight onto
|
||||
// their company's dedicated train and its wagons. Non-fatal on purpose:
|
||||
// the accept has committed; an allocation hiccup leaves the booking in
|
||||
// the day pool for the batch engine / staff instead of failing the accept.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
try {
|
||||
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const trainFresh = await this.bookingsService.findById(booking.id);
|
||||
this.emitShippingLineAccepted(trainFresh);
|
||||
this.notifier.operationAccepted(trainFresh);
|
||||
return trainFresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping-line booking becomes debt at THIS moment — Operations accepted
|
||||
* it — not at completion/pricing. Event, not a service call:
|
||||
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
|
||||
* importing its module here would close a module cycle. Emitted after the
|
||||
* accept has fully committed (including the export-capacity path, which can
|
||||
* still revert the status above), so a failed accept never creates debt.
|
||||
*/
|
||||
private emitShippingLineAccepted(booking: Booking): void {
|
||||
if (!booking.shippingLineCompanyId) return;
|
||||
this.events.emit("shipping_line_booking.accepted", {
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
amount: Number(booking.totalAmount),
|
||||
} satisfies ShippingLineBookingAcceptedPayload);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
|
||||
@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
|
||||
*/
|
||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||
|
||||
/**
|
||||
* The document set a shipping line uploads on a booking it initiated.
|
||||
*
|
||||
* Shipping lines book without a contract, so none of the trade-direction /
|
||||
* freight / customs matrix below applies to them — this one admin-configured
|
||||
* set is what Operations reviews before the booking may be completed.
|
||||
*/
|
||||
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
|
||||
'shipping_line_booking_documents';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
// Shipping-line bookings resolve to their own single set and never reach the
|
||||
// matrix below: they have no contract, and their trade direction / freight
|
||||
// type are placeholders until the booking is completed, so the customer codes
|
||||
// would resolve to a set that was never meant for them. Keyed off the owner
|
||||
// column, which is NULL on every customer booking — so no customer booking
|
||||
// can take this branch.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
return {
|
||||
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
|
||||
outputCode: null,
|
||||
includesCustoms: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||
// created with customsClearingEnabled (copied from the contract). Contract
|
||||
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||
|
||||
@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
// Every booking is billed to a company — government bookings bill to a seeded
|
||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
// Every CUSTOMER booking is billed to a company — government bookings bill to
|
||||
// a seeded government company (companies.kind = 'government'). NULL only on a
|
||||
// shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK
|
||||
// enforces that exactly one of the two is set.
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The shipping-line ACCOUNT that owns this booking, when it is not a
|
||||
* customer's. Shipping lines book without a contract and are not `companies`
|
||||
* rows (no TIN, licence or operational profiles), so they get their own owner
|
||||
* column rather than a synthetic company. NULL on every customer booking.
|
||||
*
|
||||
* Deliberately NOT `shippingLineId` above: that is cargo metadata naming the
|
||||
* carrier line that moves the goods (`freight.shipping_lines`, reference data
|
||||
* set on customer bookings too). This points at `shipping_line_companies` —
|
||||
* the portal account — and the two are unrelated.
|
||||
*
|
||||
* No relation is declared: `ShippingLineCompany` lives in its own module and
|
||||
* the column is read by id, matching how the migration leaves it FK-free.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
/**
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
@@ -125,7 +144,9 @@ export class Booking extends BaseEntity {
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
// NULL only on a shipping-line booking — shipping lines have no operational
|
||||
// profiles. Always set on a customer booking, as before.
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
} from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -96,6 +101,7 @@ export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -119,16 +125,31 @@ export class CompaniesController {
|
||||
|
||||
@Get("getInfo")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
@ApiOperation({
|
||||
summary: "Get account info for the current user (customer or shipping line)",
|
||||
})
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
if (shippingLine) {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company, review);
|
||||
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
|
||||
accountKind: "customer" as const,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -44,6 +45,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
// Fayda identity verification for the company's owner and PoA.
|
||||
VerifaydaModule,
|
||||
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
||||
// shipping-line session, which has no company row to look up. forwardRef
|
||||
// because that module imports BillingModule, which imports this one.
|
||||
forwardRef(() => ShippingLineCompaniesModule),
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
* What kind of account is signed in to the portal.
|
||||
*
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@ApiProperty({ enum: ["shipping_line"] })
|
||||
accountKind: "shipping_line" = "shipping_line";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
/**
|
||||
* Always null. Present so the portal can read `company` / `profile` off either
|
||||
* payload shape without narrowing the union first — the fields a customer
|
||||
* session carries simply have no shipping-line equivalent.
|
||||
*/
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.status = entity.status;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
@@ -100,6 +100,7 @@ export class EimsCancellationService {
|
||||
|
||||
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
|
||||
private async notifyBuyer(invoice: Invoice): Promise<void> {
|
||||
if (!invoice.companyId) return;
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
|
||||
@@ -394,7 +394,7 @@ export class EimsInvoiceRegistrationService {
|
||||
let invoiceNumber = invoiceId;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
companyId = invoice.companyId;
|
||||
companyId = invoice.companyId ?? undefined;
|
||||
invoiceNumber = invoice.invoiceNumber;
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
|
||||
@@ -219,6 +219,7 @@ export class EimsReceiptService {
|
||||
}
|
||||
|
||||
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
|
||||
if (!invoice.companyId) return;
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
|
||||
/**
|
||||
* Notification target for a shipping-line booking.
|
||||
*
|
||||
* A shipping line is NOT a `companies` row: the company IS the account — one
|
||||
* IAM user (`userId`), and the contact details live on the
|
||||
* `shipping_line_companies` row itself. So the customer resolvers
|
||||
* (external_profiles fan-out, company attributes email) never apply; this is
|
||||
* the one lookup every shipping-line notification routes through.
|
||||
*
|
||||
* Entity-only import — safe from any module graph: notifiers already own a
|
||||
* DataSource and need no service from the shipping-lines module.
|
||||
*/
|
||||
export async function resolveShippingLineNotifyTarget(
|
||||
dataSource: DataSource,
|
||||
shippingLineCompanyId: string,
|
||||
): Promise<{
|
||||
userId: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}> {
|
||||
const line = await dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { id: shippingLineCompanyId } });
|
||||
return {
|
||||
userId: line?.userId ?? null,
|
||||
phone: line?.phoneNumber ?? null,
|
||||
email: line?.email ?? null,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,14 @@ export class CreateRateDto {
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...CURRENCIES])
|
||||
|
||||
@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
trigger?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter to one shipping line\'s rates.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
isShippingLineRate?: boolean;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { CargoType } from './cargo-type.entity';
|
||||
import { ContainerType } from './container-type.entity';
|
||||
import { Yard } from './yard.entity';
|
||||
@@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
@Index(['trigger'])
|
||||
@Index(['originYardId'])
|
||||
@Index(['destinationYardId'])
|
||||
@Index(['shippingLineCompanyId'])
|
||||
export class Rate extends BaseEntity {
|
||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||
rateType!: RateType;
|
||||
@@ -155,6 +157,23 @@ export class Rate extends BaseEntity {
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard | null;
|
||||
|
||||
/**
|
||||
* The shipping line this rate belongs to, or NULL for the standard rate every
|
||||
* customer pays. A booking owned by a shipping line prices exclusively off
|
||||
* that line's rates — the standard rate is NOT a fallback, so a missing line
|
||||
* rate hard-blocks the booking rather than quietly billing the customer price.
|
||||
*
|
||||
* Points at `shipping_line_companies` (the portal account that books capacity),
|
||||
* not `shipping_lines` (carrier reference data behind the SHIPPING_LINE
|
||||
* trigger). The two are unrelated despite the similar names.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'shipping_line_company_id' })
|
||||
shippingLineCompany?: ShippingLineCompany | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface IRatesRepository {
|
||||
rateType: string;
|
||||
/** Omitted for singly-resolved rates — see the repository implementation. */
|
||||
rateUnit?: string;
|
||||
/** Owning shipping line; null/omitted = the standard customer rate. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
|
||||
@@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit?: string;
|
||||
shippingLineCompanyId?: string | null;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository {
|
||||
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
|
||||
}
|
||||
|
||||
// The owner is part of the identity: a line's rate for a lane is a
|
||||
// different rate from the standard one, not a duplicate of it.
|
||||
if (pattern.shippingLineCompanyId) {
|
||||
qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', {
|
||||
shippingLineCompanyId: pattern.shippingLineCompanyId,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('rate.shipping_line_company_id IS NULL');
|
||||
}
|
||||
|
||||
if (pattern.containerTypeId) {
|
||||
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
||||
} else {
|
||||
@@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository {
|
||||
// yards joined the route columns have only ids to render.
|
||||
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
||||
// The shipping-line tab renders the owning line's name, not its id.
|
||||
.leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany')
|
||||
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||
|
||||
if (query.shippingLineCompanyId) {
|
||||
qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', {
|
||||
shippingLineCompanyId: query.shippingLineCompanyId,
|
||||
});
|
||||
} else if (query.isShippingLineRate !== undefined) {
|
||||
// Tab filter: shipping-line rates (any line) vs standard customer rates.
|
||||
qb.andWhere(
|
||||
query.isShippingLineRate
|
||||
? 'rate.shippingLineCompanyId IS NOT NULL'
|
||||
: 'rate.shippingLineCompanyId IS NULL',
|
||||
);
|
||||
}
|
||||
if (query.status) {
|
||||
qb.andWhere('rate.status = :status', { status: query.status });
|
||||
}
|
||||
@@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module';
|
||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
@@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
|
||||
// against them (a cap above the rating is a typo, not a policy).
|
||||
WagonTypesModule,
|
||||
// Rates may be scoped to one shipping line; creating such a rate validates
|
||||
// the line exists and is active.
|
||||
ShippingLineCompaniesModule,
|
||||
],
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
|
||||
@@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — shipping-line rates override the standard ones', () => {
|
||||
const LINE = 'slc-msc';
|
||||
|
||||
/** Standard customer container-import rate on the lane. */
|
||||
const standardBase: Rate = {
|
||||
id: 'rate-standard-20',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
trigger: 'ALWAYS',
|
||||
rateValue: 1000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
/** The same lane, priced for one shipping line. */
|
||||
const lineBase: Rate = {
|
||||
...standardBase,
|
||||
id: 'rate-line-20',
|
||||
rateValue: 1200,
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
const standardHazard: Rate = {
|
||||
id: 'rate-hazard-standard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
} as Rate;
|
||||
|
||||
const lineHazard: Rate = {
|
||||
...standardHazard,
|
||||
id: 'rate-hazard-line',
|
||||
rateValue: 80,
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[]) =>
|
||||
new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||||
const bookingInput = (
|
||||
overrides: Partial<BookingEvaluationInput> = {},
|
||||
): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
|
||||
it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => {
|
||||
const result = await buildService([standardBase, lineBase]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE }),
|
||||
);
|
||||
const ow = overweightOf(result);
|
||||
expect(ow).toHaveLength(1);
|
||||
// The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t.
|
||||
expect(ow[0]).toMatchObject({
|
||||
rateId: lineBase.id,
|
||||
unitPriceUsd: 30,
|
||||
calculatedAmount: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a customer booking on the standard rate even when a line rate exists', async () => {
|
||||
const result = await buildService([standardBase, lineBase]).evaluate(bookingInput());
|
||||
const ow = overweightOf(result);
|
||||
expect(ow).toHaveLength(1);
|
||||
expect(ow[0]).toMatchObject({
|
||||
rateId: standardBase.id,
|
||||
unitPriceUsd: 25,
|
||||
calculatedAmount: 125,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fall back to the standard rate when the line has none for the lane', async () => {
|
||||
const result = await buildService([standardBase]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE }),
|
||||
);
|
||||
// No line rate on the lane → nothing to derive from. Base freight is what
|
||||
// hard-blocks the booking; the standard 1000 must never be borrowed here.
|
||||
expect(overweightOf(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('bills the line\'s own surcharge and never the standard one alongside it', async () => {
|
||||
const result = await buildService([
|
||||
standardBase,
|
||||
lineBase,
|
||||
standardHazard,
|
||||
lineHazard,
|
||||
]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }));
|
||||
|
||||
const hazard = result.appliedModifiers.filter(
|
||||
(m) => m.surchargeCode === 'HAZARD_SURCHARGE',
|
||||
);
|
||||
expect(hazard).toHaveLength(1);
|
||||
expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 });
|
||||
});
|
||||
|
||||
it('hard-blocks a requested service the line has no surcharge rate for', async () => {
|
||||
const result = await buildService([standardBase, lineBase, standardHazard]).evaluate(
|
||||
bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }),
|
||||
);
|
||||
// The standard hazard rate exists but belongs to customers, so the line's
|
||||
// hazardous booking must block rather than borrow it.
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,16 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* The shipping line that OWNS this booking (`bookings.shipping_line_company_id`),
|
||||
* when it is a shipping-line booking rather than a customer one. Such a booking
|
||||
* prices exclusively off that line's own rates — see {@link ratesForOwner}.
|
||||
*
|
||||
* Not to be confused with `shippingLineId` above, which is cargo metadata
|
||||
* naming the carrier that physically moves the goods and only feeds the
|
||||
* SHIPPING_LINE double-handling trigger.
|
||||
*/
|
||||
shippingLineCompanyId?: string | null;
|
||||
/**
|
||||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||
* this route's own container freight rate, so the engine needs the yards.
|
||||
@@ -282,7 +292,10 @@ export class RuleEngineService {
|
||||
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
|
||||
// from a non-idempotent seeder — would otherwise repeat the same surcharge
|
||||
// many times and inflate the total, so we collapse them to one row each.
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const liveRates = this.ratesForOwner(
|
||||
await this.ratesRepo.findLiveRates(),
|
||||
input.shippingLineCompanyId,
|
||||
);
|
||||
const surchargeRates = this.dedupeRatesBySignature(
|
||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||
);
|
||||
@@ -812,6 +825,28 @@ export class RuleEngineService {
|
||||
return rate.rateType ?? rate.trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the LIVE rate pool to the ones this booking's owner may price off.
|
||||
*
|
||||
* A customer booking sees only standard rates (no owner) — a shipping line's
|
||||
* negotiated price must never leak into a customer quote. A shipping-line
|
||||
* booking sees only that line's own rates: line rates OVERRIDE the standard
|
||||
* ones rather than stacking on them, and the standard rate is deliberately
|
||||
* NOT a fallback, so a lane the line has no rate for hard-blocks downstream
|
||||
* (base freight already blocks on "no rate for this route") instead of
|
||||
* quietly billing the line at the customer price.
|
||||
*
|
||||
* Filtering once, here, is what makes the override apply uniformly: every
|
||||
* downstream lookup (base freight, derived overweight, empty return, lashing,
|
||||
* fuel, and the additive surcharges) reads from this same pool, so none of
|
||||
* them needs its own owner check.
|
||||
*/
|
||||
private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] {
|
||||
return shippingLineCompanyId
|
||||
? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId)
|
||||
: rates.filter((r) => !r.shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse rates that describe the same charge to a single representative.
|
||||
*
|
||||
|
||||
@@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => {
|
||||
})),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
// Shipping line companies — these rates carry no owner, so it is never hit.
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
@@ -39,6 +41,7 @@ export class RatesService {
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
@@ -491,6 +494,7 @@ export class RatesService {
|
||||
rateType: string;
|
||||
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
||||
rateUnit?: string;
|
||||
shippingLineCompanyId: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -508,6 +512,35 @@ export class RatesService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the shipping line a rate is scoped to, when any.
|
||||
*
|
||||
* A shipping line only ever ships import — the export leg is sold through the
|
||||
* customer's contract — so a line rate carrying an EXPORT direction is
|
||||
* rejected here as well as by `CK_rates_shipping_line_import_only`.
|
||||
* Returns the owner id to store (null = the standard customer rate).
|
||||
*/
|
||||
private async resolveShippingLineScope(
|
||||
shippingLineCompanyId: string | null | undefined,
|
||||
tradeDirection: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!shippingLineCompanyId) return null;
|
||||
|
||||
// Throws NotFoundException when the line does not exist.
|
||||
const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId);
|
||||
if (line.status !== ShippingLineStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
`${line.name} is ${line.status} — rates can only be configured for an active shipping line.`,
|
||||
);
|
||||
}
|
||||
if (tradeDirection && tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'Shipping line rates are import-only — the export leg is priced through the customer contract.',
|
||||
);
|
||||
}
|
||||
return shippingLineCompanyId;
|
||||
}
|
||||
|
||||
/** Create a rate in DRAFT status. */
|
||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
||||
@@ -568,6 +601,11 @@ export class RatesService {
|
||||
destinationYardId: dto.destinationYardId,
|
||||
});
|
||||
|
||||
const shippingLineCompanyId = await this.resolveShippingLineScope(
|
||||
dto.shippingLineCompanyId,
|
||||
tradeDirection,
|
||||
);
|
||||
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
trigger,
|
||||
@@ -602,6 +640,7 @@ export class RatesService {
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
@@ -614,6 +653,7 @@ export class RatesService {
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateType,
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
@@ -791,6 +831,17 @@ export class RatesService {
|
||||
updates.originYardId = yardScope.originYardId;
|
||||
updates.destinationYardId = yardScope.destinationYardId;
|
||||
|
||||
// The owning line is re-validated on every edit: a patch that flips the
|
||||
// direction to EXPORT has to be refused for a line rate, and a patch that
|
||||
// moves the rate to a suspended line too.
|
||||
const shippingLineCompanyId = await this.resolveShippingLineScope(
|
||||
dto.shippingLineCompanyId !== undefined
|
||||
? dto.shippingLineCompanyId
|
||||
: existing.shippingLineCompanyId,
|
||||
updates.tradeDirection,
|
||||
);
|
||||
updates.shippingLineCompanyId = shippingLineCompanyId;
|
||||
|
||||
// Keep the derived rateType in sync with whatever changed.
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
@@ -839,6 +890,7 @@ export class RatesService {
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/** Payload for a shipping line cancelling its own booking. */
|
||||
export class CancelShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Why the booking is being cancelled. Recorded on the booking's review-note log.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
|
||||
|
||||
/**
|
||||
* One physical container on a line — number, seal, VGM and its per-container
|
||||
* handling switches. Same shape the customer shipment form submits.
|
||||
*/
|
||||
export class CompleteShippingLineContainerUnitDto {
|
||||
@ApiProperty({ example: "MSCU1234567" })
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmTons!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One container line the shipping line ships — container type + count, the
|
||||
* same shape the customer one-time form collects. When `units` is sent (the
|
||||
* full booking page), per-container numbers/seals/VGM and handling switches
|
||||
* are persisted exactly like the customer shipment form; without it (legacy
|
||||
* modal shape) the line-level counts stand alone.
|
||||
*/
|
||||
export class CompleteShippingLineContainerLineDto {
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerSize?: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [CompleteShippingLineContainerUnitDto],
|
||||
description:
|
||||
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompleteShippingLineContainerUnitDto)
|
||||
units?: CompleteShippingLineContainerUnitDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion payload for a shipping-line booking whose documents Operations
|
||||
* has approved (CLEARANCE_READY): the cargo and the binding shipment day —
|
||||
* the two things `initiate` deliberately left empty.
|
||||
*/
|
||||
export class CompleteShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
description: "Binding shipment day (train departure day).",
|
||||
example: "2026-09-01",
|
||||
})
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [CompleteShippingLineContainerLineDto],
|
||||
description: "Container freight: what ships. Required for CONTAINER bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompleteShippingLineContainerLineDto)
|
||||
containers?: CompleteShippingLineContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description: "Bulk freight: the cargo type. Required for BULK bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: total weight in tons. Required for BULK bookings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: hazardous portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description: "Bulk freight: refrigerated portion of the cargo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "What the containers carry." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
export class CreateShippingLineDto {
|
||||
@ApiProperty({ example: "Ethiopian Shipping Lines" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/**
|
||||
* Becomes the IAM account's email — the activation link is sent here, so it
|
||||
* is required even though the customer equivalent is optional.
|
||||
*/
|
||||
@ApiProperty({ example: "ops@esl.com.et" })
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "+251911223344" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "ESLK",
|
||||
description: "Standard Carrier Alpha Code — 2-4 letters",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z]{2,4}$/, {
|
||||
message: "SCAC must be 2-4 letters",
|
||||
})
|
||||
scacCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "IMO9074729" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
imoNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "ESLU" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bicCode?: string;
|
||||
|
||||
/**
|
||||
* Login name. Optional — defaults to the email, which is what the line will
|
||||
* naturally try first.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: "esl-ops" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity";
|
||||
|
||||
/**
|
||||
* Payload for initiating a bare shipping-line booking.
|
||||
*
|
||||
* A customer's bare booking inherits its lane from the contract it is initiated
|
||||
* under. Shipping lines have no contract, so the lane comes from a route the
|
||||
* caller picks — one choice that yields origin, destination and trade direction
|
||||
* together, rather than three fields that can contradict each other.
|
||||
*/
|
||||
export class InitiateShippingLineBookingDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.",
|
||||
})
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: "Service type being booked.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: FREIGHT_TYPES,
|
||||
description: "Freight type. Defaults to CONTAINER.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(FREIGHT_TYPES)
|
||||
freightType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
/** Finance's request to bill a batch of unbilled credits as one invoice. */
|
||||
export class GenerateCreditInvoiceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The unbilled credits to bill. All must belong to the same shipping line and share one currency.",
|
||||
type: [String],
|
||||
format: "uuid",
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID("4", { each: true })
|
||||
creditIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Pay window in days from issue. Defaults to the standard invoice term.",
|
||||
minimum: 1,
|
||||
example: 14,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/** Finance's request for a manual action on a credit invoice (maker step). */
|
||||
export class RequestInvoiceActionDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the action is needed. Shown to the approver and kept for audit.",
|
||||
example: "Paid by bank transfer, slip #TT-4491",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
|
||||
example: "TT-4491",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
paymentReference?: string;
|
||||
}
|
||||
|
||||
/** The decision on a pending request (approve and reject routes). */
|
||||
export class DecideInvoiceActionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Decision note. Required when rejecting.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Write-off of a single unbilled credit. */
|
||||
export class CancelCreditDto {
|
||||
@ApiProperty({
|
||||
description: "Why the credit is being written off. Recorded on the row.",
|
||||
example: "Booking voided before departure",
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
ShippingLineCompany,
|
||||
ShippingLineStatus,
|
||||
} from "../entities/shipping-line-company.entity";
|
||||
|
||||
export class ShippingLineResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
imoNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
bicCode?: string | null;
|
||||
|
||||
@ApiProperty({ enum: ShippingLineStatus })
|
||||
status: ShippingLineStatus;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.imoNumber = entity.imoNumber ?? null;
|
||||
this.bicCode = entity.bicCode ?? null;
|
||||
this.status = entity.status;
|
||||
this.createdAt = entity.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class RegisterShippingLineResponseDto {
|
||||
@ApiProperty({ type: ShippingLineResponseDto })
|
||||
shippingLine: ShippingLineResponseDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Masked destination the activation link was sent to, or null if delivery failed.",
|
||||
example: "o**@esl.com.et",
|
||||
})
|
||||
activationSentTo: string | null;
|
||||
|
||||
constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) {
|
||||
this.shippingLine = new ShippingLineResponseDto(shippingLine);
|
||||
this.activationSentTo = activationSentTo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
export enum ShippingLineStatus {
|
||||
Active = "active",
|
||||
Suspended = "suspended",
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping line — a carrier that books rail capacity directly, registered by
|
||||
* backoffice staff rather than self-signing up.
|
||||
*
|
||||
* Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping
|
||||
* line carries none of what `companies` exists to hold — no TIN, no business
|
||||
* licence, no eTrade authenticity lookup, no operational `company_profiles`, no
|
||||
* onboarding wizard state. Modelling it there would mean making all of that
|
||||
* nullable for one row shape that never uses it.
|
||||
*
|
||||
* The company IS the account: there is no contact-person row (customers get one
|
||||
* via `external_profiles`), so `user_id` lives here and the login credentials
|
||||
* are the company's own. That is also why the password-reset flow resolves a
|
||||
* shipping line straight off this table instead of through a primary contact.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_companies" })
|
||||
@Index(["status"])
|
||||
export class ShippingLineCompany extends BaseEntity {
|
||||
/**
|
||||
* The IAM account (`iam.users`, userType `individual`) that signs in as this
|
||||
* shipping line. No FK: `iam` is a separate schema owned by the IAM service,
|
||||
* and the rest of the codebase reaches it by query rather than by relation.
|
||||
*/
|
||||
@Column({ name: "user_id", type: "uuid", unique: true })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
|
||||
/** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */
|
||||
@Column({ name: "scac_code", type: "varchar", length: 4, nullable: true })
|
||||
scacCode?: string | null;
|
||||
|
||||
/** IMO number of the vessel operator. */
|
||||
@Column({ name: "imo_number", type: "varchar", length: 20, nullable: true })
|
||||
imoNumber?: string | null;
|
||||
|
||||
/** BIC code — the container prefix the line's equipment is registered under. */
|
||||
@Column({ name: "bic_code", type: "varchar", length: 20, nullable: true })
|
||||
bicCode?: string | null;
|
||||
|
||||
/** Mirrors the IAM account's email; the activation link is sent here. */
|
||||
@Column({ name: "email", type: "varchar", length: 150 })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineStatus,
|
||||
default: ShippingLineStatus.Active,
|
||||
})
|
||||
status!: ShippingLineStatus;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
import { ShippingLineCompany } from "./shipping-line-company.entity";
|
||||
|
||||
/** Where a credit sits between "service used" and "money received". */
|
||||
export enum ShippingLineCreditStatus {
|
||||
/** Service used, priced, not yet on any invoice. Counts as debt. */
|
||||
Unbilled = "UNBILLED",
|
||||
/** Finance put it on an invoice; awaiting payment. Still counts as debt. */
|
||||
Billed = "BILLED",
|
||||
/** The invoice settled. Terminal — no longer debt, and never re-billed. */
|
||||
Paid = "PAID",
|
||||
/** Written off / booking voided. Terminal, excluded from every total. */
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Statuses a shipping line still owes money for. */
|
||||
export const OUTSTANDING_CREDIT_STATUSES = [
|
||||
ShippingLineCreditStatus.Unbilled,
|
||||
ShippingLineCreditStatus.Billed,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* What a shipping line owes for one booking.
|
||||
*
|
||||
* Shipping lines get the service first and pay later, so a booking of theirs
|
||||
* raises no invoice and passes no payment gate — it raises one of these. The
|
||||
* amount is frozen when the booking is priced and is never recalculated, so a
|
||||
* later rate change cannot silently alter a debt already incurred.
|
||||
*
|
||||
* Finance batches unbilled credits into one invoice (see
|
||||
* `ShippingLineCreditsService.generateInvoice`); the line pays that invoice
|
||||
* through the ordinary CBE flow; settlement flips the batch to PAID and the
|
||||
* debt disappears. The outstanding figure is always derived by summing
|
||||
* {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column,
|
||||
* because a stored balance is one missed UPDATE away from being a lie.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_credits" })
|
||||
@Index(["shippingLineCompanyId", "status"])
|
||||
@Index(["invoiceId"])
|
||||
export class ShippingLineCredit extends BaseEntity {
|
||||
/** The line that owes this. */
|
||||
@Column({ name: "shipping_line_company_id", type: "uuid" })
|
||||
shippingLineCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany)
|
||||
@JoinColumn({ name: "shipping_line_company_id" })
|
||||
shippingLineCompany?: ShippingLineCompany;
|
||||
|
||||
/**
|
||||
* The booking that incurred the charge. Unique among live rows (partial
|
||||
* index excludes soft-deleted and CANCELLED), so one booking can never be
|
||||
* billed twice.
|
||||
*/
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** Frozen at pricing time. Never recalculated. */
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineCreditStatus,
|
||||
default: ShippingLineCreditStatus.Unbilled,
|
||||
})
|
||||
status!: ShippingLineCreditStatus;
|
||||
|
||||
/** What the charge is for; becomes the invoice line description. */
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** The invoice this credit was billed on; null while UNBILLED. */
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
/** When finance put it on an invoice. */
|
||||
@Column({ name: "billed_at", type: "timestamptz", nullable: true })
|
||||
billedAt?: Date | null;
|
||||
|
||||
/** When that invoice settled. */
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: "cancelled_at", type: "timestamptz", nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "cancellation_reason",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
cancellationReason?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||
|
||||
/** What finance asked to do to a shipping-line credit invoice. */
|
||||
export enum ShippingLineInvoiceActionType {
|
||||
/** Record a full offline settlement (paid outside the gateway). */
|
||||
MarkPaid = "MARK_PAID",
|
||||
/** Void the invoice; its credits return to the unbilled pool. */
|
||||
Cancel = "CANCEL",
|
||||
}
|
||||
|
||||
export enum ShippingLineInvoiceActionStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* Marking an invoice paid by hand erases real debt, and cancelling one
|
||||
* releases its credits back to the unbilled pool — either done unilaterally is
|
||||
* a one-person fraud path. So finance REQUESTS the action (one permission)
|
||||
* and a chief APPROVES or REJECTS it (a separate permission, different
|
||||
* person). Every request is kept, decided or not: the table is the audit
|
||||
* trail of who asked, who decided, and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
|
||||
@Index(["invoiceId", "status"])
|
||||
export class ShippingLineInvoiceApproval extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid" })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice?: Invoice;
|
||||
|
||||
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
|
||||
action!: ShippingLineInvoiceActionType;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineInvoiceActionStatus,
|
||||
default: ShippingLineInvoiceActionStatus.Pending,
|
||||
})
|
||||
status!: ShippingLineInvoiceActionStatus;
|
||||
|
||||
/** IAM user id of the finance staff who raised the request. */
|
||||
@Column({ name: "requested_by", type: "uuid" })
|
||||
requestedBy!: string;
|
||||
|
||||
/** Why the action is needed; shown to the approver, kept for audit. */
|
||||
@Column({ name: "reason", type: "varchar", length: 500 })
|
||||
reason!: string;
|
||||
|
||||
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
|
||||
@Column({
|
||||
name: "payment_reference",
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
paymentReference?: string | null;
|
||||
|
||||
/** IAM user id of the chief who approved/rejected; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
|
||||
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion half of shipping-line bookings, sharing the
|
||||
* `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}.
|
||||
* Separate controller because it lives in its own module — see
|
||||
* {@link ShippingLineBookingCompletionService} for why the module split exists.
|
||||
*/
|
||||
@ApiTags("shipping-line-bookings")
|
||||
@Controller("shipping-line-bookings")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineBookingCompletionController {
|
||||
constructor(
|
||||
private readonly completionService: ShippingLineBookingCompletionService,
|
||||
) {}
|
||||
|
||||
@Get(":id/available-days")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
|
||||
})
|
||||
async availableDays(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.completionService.availableDaysMine(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/trains")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
|
||||
})
|
||||
async trainsForDay(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("date") date?: string,
|
||||
@Query("sizes") sizes?: string,
|
||||
@Query("cargoTypeId") cargoTypeId?: string,
|
||||
@Query("wagons") wagons?: string,
|
||||
) {
|
||||
return this.completionService.trainsForDayMine(user.id, id, date, {
|
||||
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
|
||||
cargoTypeId: cargoTypeId || undefined,
|
||||
wagons: wagons ? Number(wagons) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":id/price-preview")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
|
||||
})
|
||||
async pricePreview(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
return this.completionService.previewPriceMine(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/operations")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
|
||||
})
|
||||
async operations(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.completionService.operationsMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
|
||||
})
|
||||
async completeMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
return this.completionService.completeMine(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BookingsModule } from "../bookings/bookings.module";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller";
|
||||
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
|
||||
import { ShippingLineCompaniesModule } from "./shipping-line-companies.module";
|
||||
|
||||
/**
|
||||
* Deliberately a LEAF module — registered in AppModule and imported by
|
||||
* nothing. Completion needs BookingsModule (pricing + the operation-request
|
||||
* transition), but ShippingLineCompaniesModule sits under rule-engine and
|
||||
* companies, which sit under BookingsModule; importing bookings from there
|
||||
* closes a module cycle Nest cannot construct. Keeping the completion flow
|
||||
* here keeps the graph acyclic with no forwardRef chains.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Booking]),
|
||||
BookingsModule,
|
||||
TrainSchedulingModule,
|
||||
ShippingLineCompaniesModule,
|
||||
],
|
||||
controllers: [ShippingLineBookingCompletionController],
|
||||
providers: [ShippingLineBookingCompletionService],
|
||||
})
|
||||
export class ShippingLineBookingCompletionModule {}
|
||||
@@ -0,0 +1,766 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
||||
import { BookingsService } from "../bookings/bookings.service";
|
||||
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
||||
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import {
|
||||
BookingBatchService,
|
||||
type TrainOptionCargoOverrides,
|
||||
} from "../train-scheduling/booking-batch.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
||||
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
|
||||
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
/**
|
||||
* Completion of a shipping-line booking — the step after Operations approves
|
||||
* its documents, mirroring what a customer does at that point: cargo + binding
|
||||
* shipment day go in, the booking prices off the line's negotiated rates and
|
||||
* the request lands with Operations.
|
||||
*
|
||||
* Its own module (not part of {@link ShippingLineBookingsService}) because it
|
||||
* needs BookingsModule (pricing, the operation-request transition) and
|
||||
* TrainSchedulingModule — and ShippingLineCompaniesModule is imported by
|
||||
* rule-engine/companies, which sit UNDER BookingsModule. Importing bookings
|
||||
* from there closes a module cycle Nest cannot construct; a leaf module that
|
||||
* nothing imports keeps the graph acyclic.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineBookingCompletionService {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly creditsService: ShippingLineCreditsService,
|
||||
) {}
|
||||
|
||||
/** Same session→owner resolution every shipping-line entry point uses. */
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
await this.shippingLineCompaniesService.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
if (shippingLine.status !== "active") {
|
||||
throw new ForbiddenException(
|
||||
"This shipping-line account is suspended and cannot create bookings.",
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
private async requireOwnBooking(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
relations?: { bookingContainers?: boolean },
|
||||
) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
relations,
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED,
|
||||
* soonest first). These trains run NO booking-window cycle — the line books
|
||||
* whenever it wants until the close offset stamped in `windowClosesAt` — and
|
||||
* they are excluded from every customer pool, so this is the only source
|
||||
* that can offer them.
|
||||
*/
|
||||
private async dedicatedTrainsForBooking(booking: Booking) {
|
||||
if (!booking.shippingLineCompanyId) return [];
|
||||
return this.bookingsRepository.manager.getRepository(TrainSchedule).find({
|
||||
where: {
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId ?? undefined,
|
||||
destinationStationId: booking.destinationYardId ?? undefined,
|
||||
status: In(["DRAFT", "SCHEDULED"]),
|
||||
},
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Still bookable: the close offset before departure has not passed yet. */
|
||||
private isStillOpen(schedule: TrainSchedule): boolean {
|
||||
const closesAt =
|
||||
schedule.windowClosesAt ?? schedule.scheduledDepartureDate;
|
||||
return closesAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's dedicated trains on the booking's lane for one shipment day,
|
||||
* each with per-wagon-type free space — the completion form's train picker.
|
||||
* A booking rides ONE schedule, so with several departures that day the
|
||||
* line picks which; the pick is validated again at complete time.
|
||||
*/
|
||||
async trainsForDayMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
date: string | undefined,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
if (!booking.shippingLineCompanyId) return [];
|
||||
return this.bookingBatchService.dedicatedTrainOptionsForDay(
|
||||
booking,
|
||||
date ? eatDay(new Date(date)) : null,
|
||||
booking.shippingLineCompanyId,
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the shipping line may pick as the shipment day.
|
||||
*
|
||||
* Lanes with trains DEDICATED to this line offer exactly those trains' days,
|
||||
* open until each train's close offset — no window cycle. Lanes without a
|
||||
* dedicated train fall back to the shared customer day pool, exactly as
|
||||
* before. Ownership is checked first so one line cannot probe another's
|
||||
* booking.
|
||||
*/
|
||||
async availableDaysMine(userId: string, bookingId: string) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
||||
if (dedicated.length === 0) {
|
||||
return this.bookingsService.availableDaysForBooking(bookingId);
|
||||
}
|
||||
const days = [
|
||||
...new Set(
|
||||
dedicated
|
||||
.filter((s) => this.isStillOpen(s))
|
||||
.map((s) => eatDay(s.scheduledDepartureDate)),
|
||||
),
|
||||
];
|
||||
return { days };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a bare shipping-line booking once Operations has approved its
|
||||
* documents (CLEARANCE_READY), or after Operations returned the request
|
||||
* (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go
|
||||
* in, the booking is priced off the line's negotiated rates, and the request
|
||||
* lands with Operations (OPERATION_REQUEST_PENDING) through the same
|
||||
* transition customers use.
|
||||
*
|
||||
* Payment differs from customers by design: no invoice is issued here.
|
||||
* Shipping lines run on the credit ledger — the priced amount is recorded as
|
||||
* an UNBILLED credit and Finance bills a batch later, so the booking
|
||||
* proceeds without a payment gate.
|
||||
*/
|
||||
async completeMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId, {
|
||||
bookingContainers: true,
|
||||
});
|
||||
if (
|
||||
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Your documents must be approved before the booking can be completed.",
|
||||
);
|
||||
}
|
||||
|
||||
// Completion is booking time. A lane with trains DEDICATED to this line
|
||||
// has no window concept at all: the line books whenever it wants until the
|
||||
// train's close offset. Only a lane with no dedicated train falls back to
|
||||
// the customer window gate, unchanged.
|
||||
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
||||
const pickedDay = eatDay(new Date(dto.scheduledDate));
|
||||
const dedicatedOnDay = dedicated.filter(
|
||||
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
|
||||
);
|
||||
let bypassDayPool = false;
|
||||
let requestedTrainScheduleId: string | null = null;
|
||||
if (dedicatedOnDay.length > 0) {
|
||||
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
|
||||
if (openOnDay.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Booking for your train on this day has closed — the cut-off before departure has passed.",
|
||||
);
|
||||
}
|
||||
// A booking rides ONE schedule. Several departures that day → the line
|
||||
// must say which; a single one is picked implicitly. The id comes from
|
||||
// the request, so it is validated against the day's own trains.
|
||||
if (dto.trainScheduleId) {
|
||||
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
|
||||
if (!picked) {
|
||||
throw new BadRequestException(
|
||||
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
|
||||
);
|
||||
}
|
||||
requestedTrainScheduleId = picked.id;
|
||||
} else if (openOnDay.length === 1) {
|
||||
requestedTrainScheduleId = openOnDay[0].id;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"More than one of your trains departs that day — select which train this booking rides.",
|
||||
);
|
||||
}
|
||||
// The day is backed by the line's own train, which every customer pool
|
||||
// deliberately excludes — so the day-pool gate downstream must not run.
|
||||
bypassDayPool = true;
|
||||
} else if (dedicated.length > 0) {
|
||||
throw new BadRequestException(
|
||||
"Pick one of your assigned train days for this route.",
|
||||
);
|
||||
} else {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: booking.originYardId ?? null,
|
||||
destinationYardId: booking.destinationYardId ?? null,
|
||||
scheduledDate: dto.scheduledDate,
|
||||
direction: booking.tradeDirection ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
const restatesCargo = Boolean(
|
||||
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
|
||||
);
|
||||
|
||||
// Operations may return the request asking for the CARGO to change, not
|
||||
// just the day. A resubmit that restates cargo starts completion over:
|
||||
// the recorded (unbilled) credit is written off and the persisted cargo
|
||||
// wiped, so the fresh path below re-persists, re-prices and re-records.
|
||||
// Once the credit is on an issued invoice the cargo is frozen — the
|
||||
// invoice total must keep matching what it bills.
|
||||
if (hasCargo && restatesCargo) {
|
||||
const credit = await this.bookingsRepository.manager
|
||||
.getRepository(ShippingLineCredit)
|
||||
.findOne({ where: { bookingId } });
|
||||
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
|
||||
await this.creditsService.cancelCredit(
|
||||
credit.id,
|
||||
"Cargo changed before billing — booking re-priced on completion.",
|
||||
);
|
||||
} else if (
|
||||
credit &&
|
||||
credit.status !== ShippingLineCreditStatus.Cancelled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
|
||||
);
|
||||
}
|
||||
await this.wipeCargo(bookingId);
|
||||
hasCargo = false;
|
||||
}
|
||||
|
||||
// First completion persists cargo and prices the booking; a day-only
|
||||
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
|
||||
// operation request with the cargo (and price) it already carries.
|
||||
if (!hasCargo) {
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
await this.persistContainerLines(booking, dto);
|
||||
} else {
|
||||
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Bulk bookings need a cargo type and a total weight in tons.",
|
||||
);
|
||||
}
|
||||
const cargoType = await this.bookingsRepository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(
|
||||
`Cargo type ${dto.cargoTypeId} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
cargoTypeId:
|
||||
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm:
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
|
||||
bulkTotalWeightTons:
|
||||
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
|
||||
// Bulk handling portions — sized against the cargo, billed by pricing.
|
||||
...(booking.freightType === "BULK"
|
||||
? {
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
}
|
||||
: {}),
|
||||
// Hazard is per-line for containers; the booking-level flag is what
|
||||
// pricing bills the surcharge from.
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Same for reefer: the rule engine's REEFER trigger fires on the
|
||||
// booking-level flag (or a reefer container TYPE) — a ticked reefer
|
||||
// switch on a standard box only sets the per-line count, so without
|
||||
// this flag the surcharge silently never bills.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
// Shipping lines are always billed in ETB: the charge lands on the
|
||||
// ETB credit ledger, so the currency is enforced here rather than
|
||||
// trusted from the payload.
|
||||
paymentCurrency: "ETB",
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: true, serviceType: true },
|
||||
});
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(
|
||||
loaded ?? booking,
|
||||
);
|
||||
// A zero price or hard block means no rate is configured for this line
|
||||
// on this lane. Roll the cargo back so the booking stays completable —
|
||||
// the approved clearance is not lost — and surface why.
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
await this.wipeCargo(bookingId);
|
||||
throw new BadRequestException(
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join("; ")
|
||||
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
// No credit is recorded here: completion only REQUESTS the operation.
|
||||
// The charge lands on the line's ledger when Operations accepts —
|
||||
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
|
||||
// request that is returned or never accepted creates no debt.
|
||||
}
|
||||
|
||||
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
|
||||
// machine a customer booking uses. When the day is backed by a dedicated
|
||||
// train, the customer day-pool gate is skipped (validated above instead).
|
||||
return this.bookingTransitionService.requestOperation(
|
||||
bookingId,
|
||||
dto.scheduledDate,
|
||||
requestedTrainScheduleId,
|
||||
bypassDayPool ? { bypassDayPool: true } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative price preview for the completion form's confirm step: the
|
||||
* SAME compute the completion itself runs, over an in-memory probe shaped
|
||||
* exactly like completeMine would persist the booking — so the figure the
|
||||
* shipping line confirms is line-for-line what it will owe.
|
||||
*
|
||||
* The result is not advisory-only: the breakdown is saved on the booking and
|
||||
* the rate snapshots are (re)written, so every re-preview refreshes them.
|
||||
* Nothing else is persisted — no cargo rows, no credit, no transition.
|
||||
*/
|
||||
async previewPriceMine(
|
||||
userId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId, {
|
||||
bookingContainers: true,
|
||||
});
|
||||
if (
|
||||
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Your documents must be approved before the booking can be priced.",
|
||||
);
|
||||
}
|
||||
|
||||
// In-memory cargo, mirroring what completeMine persists.
|
||||
let probeContainers: Partial<BookingContainer>[] = [];
|
||||
let bulkFields: Record<string, unknown> = {};
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException(
|
||||
"At least one container line is required.",
|
||||
);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
const figures = this.lineFigures(line);
|
||||
probeContainers.push({
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: containerType.sizeFt
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Bulk bookings need a cargo type and a total weight in tons.",
|
||||
);
|
||||
}
|
||||
const cargoType = await this.bookingsRepository.manager
|
||||
.getRepository(CargoType)
|
||||
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
|
||||
if (!cargoType) {
|
||||
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
bulkFields = {
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
|
||||
bulkTotalWeightTons: Number(dto.cargoWeightTons),
|
||||
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
||||
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
||||
};
|
||||
probeContainers = [];
|
||||
}
|
||||
|
||||
// Prototype-preserving clone so entity getters keep working — the same
|
||||
// probe trick the contract preview uses.
|
||||
const probe = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(booking)),
|
||||
booking,
|
||||
{
|
||||
bookingContainers: probeContainers,
|
||||
paymentCurrency: "ETB",
|
||||
isHazardous:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.hazardousQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isHazardous),
|
||||
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
||||
// Mirrors completeMine: without the booking-level flag the engine's
|
||||
// REEFER trigger never fires for reefer opt-ins on standard boxes,
|
||||
// and the quote would show base freight only.
|
||||
isReefer:
|
||||
(dto.containers ?? []).some(
|
||||
(line) =>
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer),
|
||||
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
||||
...bulkFields,
|
||||
},
|
||||
) as Booking;
|
||||
|
||||
const computed =
|
||||
await this.bookingPricingService.computePriceForBooking(probe);
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join("; ")
|
||||
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the quoted figure: breakdown on the booking, snapshots of the
|
||||
// rates it was built from. createPricingSnapshots clears the previous
|
||||
// artifacts first, so a re-preview replaces the old quote rather than
|
||||
// stacking a second one.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What operations has done with the booking so far: the train it rides
|
||||
* (assigned, or the requested one before assignment) and the wagons the
|
||||
* batch engine allocated to it, with any container numbers loaded per wagon.
|
||||
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
|
||||
*/
|
||||
async operationsMine(userId: string, bookingId: string) {
|
||||
const booking = await this.requireOwnBooking(userId, bookingId);
|
||||
const manager = this.bookingsRepository.manager;
|
||||
|
||||
const scheduleId =
|
||||
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
|
||||
let train: Record<string, unknown> | null = null;
|
||||
if (scheduleId) {
|
||||
const schedule = await manager.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
});
|
||||
if (schedule) {
|
||||
train = {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference,
|
||||
trainNumber: schedule.trainNumber,
|
||||
status: schedule.status,
|
||||
direction: schedule.direction,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
originLabel:
|
||||
schedule.originStation?.label ??
|
||||
schedule.originStation?.code ??
|
||||
"Origin",
|
||||
destinationLabel:
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
"Destination",
|
||||
// Whether this is the confirmed assignment or still the request.
|
||||
assigned: Boolean(booking.trainScheduleId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allocations = await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.find({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
trainSetWagon: { wagonType: true, physicalWagon: true },
|
||||
containerItems: true,
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
|
||||
const wagons = allocations.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
status: allocation.status,
|
||||
loadType: allocation.loadType,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons),
|
||||
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
|
||||
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType:
|
||||
allocation.trainSetWagon?.wagonType?.name ??
|
||||
allocation.trainSetWagon?.wagonType?.code ??
|
||||
null,
|
||||
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
}));
|
||||
|
||||
return { train, wagons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a line's container type: by id when the payload carries one, else
|
||||
* from the size string ("40ft" → the active 40ft type, preferring the reefer
|
||||
* variant when the line ships reefer boxes). Resolution lives HERE, not in
|
||||
* the portal, so a slow or failed catalog fetch can never block a booking
|
||||
* with a phantom "type not configured" error — mirrors the customer flow's
|
||||
* server-side size→type mapping.
|
||||
*/
|
||||
private async resolveContainerType(line: {
|
||||
containerTypeId?: string;
|
||||
containerSize?: string;
|
||||
reeferQuantity?: number;
|
||||
units?: { isReefer?: boolean }[];
|
||||
}): Promise<ContainerType> {
|
||||
const containerTypeRepo =
|
||||
this.bookingsRepository.manager.getRepository(ContainerType);
|
||||
|
||||
if (line.containerTypeId) {
|
||||
const byId = await containerTypeRepo.findOne({
|
||||
where: { id: line.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!byId) {
|
||||
throw new NotFoundException(
|
||||
`Container type ${line.containerTypeId} not found`,
|
||||
);
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
const sizeFt = parseInt(line.containerSize ?? "", 10);
|
||||
if (!Number.isFinite(sizeFt)) {
|
||||
throw new BadRequestException(
|
||||
"Each container line needs a containerTypeId or a containerSize.",
|
||||
);
|
||||
}
|
||||
const candidates = await containerTypeRepo.find({
|
||||
where: { isActive: true },
|
||||
});
|
||||
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
|
||||
if (!ofSize.length) {
|
||||
throw new BadRequestException(
|
||||
`No ${sizeFt}ft container type is configured — please contact Operations.`,
|
||||
);
|
||||
}
|
||||
const wantsReefer =
|
||||
Number(line.reeferQuantity ?? 0) > 0 ||
|
||||
(line.units ?? []).some((u) => u.isReefer);
|
||||
if (wantsReefer) {
|
||||
const reefer = ofSize.find((ct) => ct.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A line's derived figures. With per-container rows (the full booking page),
|
||||
* counts and VGM come FROM the rows — each container's switches are the
|
||||
* source of truth. Without them, the line-level figures stand alone.
|
||||
*/
|
||||
private lineFigures(line: {
|
||||
quantity: number;
|
||||
vgmPerUnitTons?: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
|
||||
}) {
|
||||
const units = line.units ?? [];
|
||||
const hazardous = units.length
|
||||
? units.filter((u) => u.isHazardous).length
|
||||
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
|
||||
const reefer = units.length
|
||||
? units.filter((u) => u.isReefer).length
|
||||
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
|
||||
const totalVgm = units.length
|
||||
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
|
||||
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
|
||||
const vgmPerUnit = units.length
|
||||
? totalVgm / units.length
|
||||
: Number(line.vgmPerUnitTons ?? 0);
|
||||
return { hazardous, reefer, totalVgm, vgmPerUnit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the container lines of a CONTAINER completion. Same row shape the
|
||||
* customer paths write (quantity per type, VGM totals, wagon share) — the
|
||||
* per-unit ISO numbers customers also skip at booking time arrive later at
|
||||
* yard operations.
|
||||
*/
|
||||
private async persistContainerLines(
|
||||
booking: Booking,
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
): Promise<void> {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException("At least one container line is required.");
|
||||
}
|
||||
|
||||
const containerRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainer);
|
||||
const unitRepo =
|
||||
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
// Counts and VGM derived by lineFigures — the same math the price
|
||||
// preview runs, so the persisted cargo always matches the quote.
|
||||
const units = line.units ?? [];
|
||||
const figures = this.lineFigures(line);
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: containerType.sizeFt
|
||||
? `${containerType.sizeFt}ft`
|
||||
: null,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: figures.hazardous,
|
||||
reeferQuantity: figures.reefer,
|
||||
returnQuantity: 0,
|
||||
vgmPerUnitTons: figures.vgmPerUnit,
|
||||
totalVgmTons: figures.totalVgm,
|
||||
wagonsRequired: Math.ceil(
|
||||
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
||||
),
|
||||
}),
|
||||
);
|
||||
let sortOrder = 0;
|
||||
for (const unit of units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber.trim().toUpperCase(),
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: Number(unit.vgmTons ?? 0),
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn: false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Roll a failed/superseded completion back to the bare-booking shape. */
|
||||
private async wipeCargo(bookingId: string): Promise<void> {
|
||||
await this.bookingsRepository.manager
|
||||
.getRepository(BookingContainer)
|
||||
.softDelete({ bookingId });
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 0,
|
||||
bulkTotalWeightTons: null,
|
||||
totalAmount: 0,
|
||||
pricingBreakdown: null,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings a shipping line makes for itself, from the portal.
|
||||
*
|
||||
* Separate from `/bookings` (customers) on purpose — see
|
||||
* {@link ShippingLineBookingsService} for why the two flows are not merged.
|
||||
* `PortalCustomer` only proves a valid portal session; the service resolves the
|
||||
* shipping-line account from that session and rejects anyone else, so the owner
|
||||
* is never taken from the request body.
|
||||
*/
|
||||
@ApiTags("shipping-line-bookings")
|
||||
@Controller("shipping-line-bookings")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineBookingsController {
|
||||
constructor(
|
||||
private readonly shippingLineBookingsService: ShippingLineBookingsService,
|
||||
) {}
|
||||
|
||||
@Post("initiate")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.",
|
||||
})
|
||||
async initiate(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: InitiateShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.initiate(user.id, dto);
|
||||
}
|
||||
|
||||
// Declared before @Get(":id") so the path isn't captured as a booking id.
|
||||
@Get("reference-data")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.",
|
||||
})
|
||||
async referenceData(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.referenceData(user.id);
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "List the signed-in shipping line's bookings." })
|
||||
async listMine(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.listMine(user.id);
|
||||
}
|
||||
|
||||
// Declared before @Get(":id") so the path isn't captured as a booking id.
|
||||
@Get("my-trains")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.",
|
||||
})
|
||||
async listMyTrains(@CurrentUser() user: CurrentIamUser) {
|
||||
return this.shippingLineBookingsService.listMyTrains(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." })
|
||||
async findMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.shippingLineBookingsService.findMine(user.id, id);
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.",
|
||||
})
|
||||
async cancelMine(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelShippingLineBookingDto,
|
||||
) {
|
||||
return this.shippingLineBookingsService.cancelMine(
|
||||
user.id,
|
||||
id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { insertWithGeneratedReference } from "@edr/api-common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, MoreThanOrEqual, Repository } from "typeorm";
|
||||
|
||||
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
|
||||
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { ServiceType } from "../rule-engine/entities/service-type.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* The only trade direction a shipping line books.
|
||||
*
|
||||
* Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is
|
||||
* IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT,
|
||||
* same country = DOMESTIC). Export and intercity lanes are therefore neither
|
||||
* offered nor accepted.
|
||||
*/
|
||||
const SHIPPING_LINE_DIRECTION = "IMPORT";
|
||||
|
||||
/**
|
||||
* Statuses a shipping line may cancel its own booking from — everything before
|
||||
* the booking is priced. Past this point cancelling has billing consequences
|
||||
* (fees, credit notes) and belongs with Operations.
|
||||
*/
|
||||
const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"CHANGES_REQUESTED",
|
||||
];
|
||||
|
||||
/**
|
||||
* Booking creation for shipping lines.
|
||||
*
|
||||
* Deliberately separate from `BookingsService` / `ContractBookingService`
|
||||
* rather than a branch inside them. Those are built end to end around a
|
||||
* customer: a `companies` row, an approved operational `company_profile`, a
|
||||
* contract supplying route/quantities, and contract-capacity accounting. A
|
||||
* shipping line has none of that — it books directly, without a contract — so
|
||||
* branching there would mean threading "no company, no profile, no contract"
|
||||
* through every method a customer booking passes through. Keeping it here means
|
||||
* the customer paths are not touched at all.
|
||||
*
|
||||
* What IS shared is the table and the downstream lifecycle: the row lands in
|
||||
* `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its
|
||||
* documents against the `shipping_line_booking_documents` file-upload setting,
|
||||
* and Operations reviews and finalizes them through the same clearance flow
|
||||
* customers already use.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineBookingsService {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the shipping-line account for a signed-in user, or reject. Every
|
||||
* entry point goes through this: the owner is taken from the session, never
|
||||
* from the request body, so one shipping line cannot book as another.
|
||||
*/
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
await this.shippingLineCompaniesService.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
if (shippingLine.status !== "active") {
|
||||
throw new ForbiddenException(
|
||||
"This shipping-line account is suspended and cannot create bookings.",
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog the initiate form needs: the lanes EDR actually runs, and the
|
||||
* services that can be booked on their own.
|
||||
*
|
||||
* Routes are offered instead of two loose yard pickers so a shipping line
|
||||
* cannot invent a lane that does not exist — and because the route already
|
||||
* carries its trade direction, which is otherwise guesswork.
|
||||
*
|
||||
* Read-only and scoped to bookable rows, which is why it lives here rather
|
||||
* than reusing the staff `/routes` controller (gated behind fleet
|
||||
* permissions a shipping line does not and should not hold).
|
||||
*/
|
||||
async referenceData(userId: string) {
|
||||
await this.requireShippingLine(userId);
|
||||
|
||||
const [routes, serviceTypes, containerTypes, cargoTypes] =
|
||||
await Promise.all([
|
||||
this.bookingsRepository.manager.getRepository(Route).find({
|
||||
// Shipping lines only move inbound cargo: it lands at the Djibouti port
|
||||
// and runs inland to Ethiopia. Filtering here rather than in the portal
|
||||
// means an export or intercity lane is never offered AND never
|
||||
// accepted — `initiate` re-checks the same rule below.
|
||||
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
}),
|
||||
// Customs-bundled services are excluded: those run the phased ET/DJ
|
||||
// customs workflow, which is a contract-backed flow a shipping line has
|
||||
// no part in. Their clearance is the single document set Operations
|
||||
// reviews on the booking itself.
|
||||
this.bookingsRepository.manager.getRepository(ServiceType).find({
|
||||
where: {
|
||||
canBeBookedAlone: true,
|
||||
includesCustoms: false,
|
||||
isActive: true,
|
||||
},
|
||||
order: { displayOrder: "ASC" },
|
||||
}),
|
||||
// For the completion form: what ships. Container types for CONTAINER
|
||||
// bookings, cargo types for BULK ones.
|
||||
this.bookingsRepository.manager.getRepository(ContainerType).find({
|
||||
where: { isActive: true },
|
||||
}),
|
||||
this.bookingsRepository.manager.getRepository(CargoType).find({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
routes: routes.map((route) => ({
|
||||
id: route.id,
|
||||
label: formatRouteLabel(route),
|
||||
direction: route.direction,
|
||||
originYardId: route.originYardId,
|
||||
// Per-yard labels so the portal can offer origin and destination as two
|
||||
// separate pickers (the shape the customer form uses) while still
|
||||
// resolving the pair back to one of these routes.
|
||||
originLabel:
|
||||
route.originYard?.label ?? route.originYard?.code ?? "Origin",
|
||||
destinationYardId: route.destinationYardId,
|
||||
destinationLabel:
|
||||
route.destinationYard?.label ??
|
||||
route.destinationYard?.code ??
|
||||
"Destination",
|
||||
})),
|
||||
serviceTypes: serviceTypes.map((service) => ({
|
||||
id: service.id,
|
||||
name: service.serviceName,
|
||||
})),
|
||||
containerTypes: containerTypes.map((ct) => ({
|
||||
id: ct.id,
|
||||
label: ct.label ?? ct.code,
|
||||
sizeFt: ct.sizeFt,
|
||||
isReefer: ct.isReefer,
|
||||
})),
|
||||
// parentGroupId lets the portal tell leaf types from grouping rows.
|
||||
cargoTypes: cargoTypes.map((cargo) => ({
|
||||
id: cargo.id,
|
||||
name: cargo.cargoTypeName,
|
||||
parentGroupId: cargo.parentGroupId ?? null,
|
||||
unitOfMeasure: cargo.unitOfMeasure ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BARE booking for a shipping line — no contract, no cargo, no date
|
||||
* and no price. It exists so documents have something to hang off: the
|
||||
* shipping line uploads them next, Operations approves, and only then is the
|
||||
* booking completed with its cargo and shipment day.
|
||||
*/
|
||||
async initiate(userId: string, dto: InitiateShippingLineBookingDto) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
// The route is the single source of origin, destination AND direction —
|
||||
// resolved server-side so the three can never disagree, and so a caller
|
||||
// cannot post a lane EDR does not run.
|
||||
const route = await this.bookingsRepository.manager
|
||||
.getRepository(Route)
|
||||
.findOne({ where: { id: dto.routeId } });
|
||||
if (!route) {
|
||||
throw new NotFoundException(`Route ${dto.routeId} not found`);
|
||||
}
|
||||
if (route.status !== "AVAILABLE") {
|
||||
throw new BadRequestException(
|
||||
"This route is not currently available for booking.",
|
||||
);
|
||||
}
|
||||
// Enforced here too, not just by filtering the picker: the route id comes
|
||||
// from the request, so an export or intercity lane could otherwise be
|
||||
// posted directly.
|
||||
if (route.direction !== SHIPPING_LINE_DIRECTION) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines can only book inbound (Djibouti to Ethiopia) routes.",
|
||||
);
|
||||
}
|
||||
|
||||
// Only a forward-looking day makes sense; train validation happens later
|
||||
// when Operations schedules it, so only the past is rejected here.
|
||||
let scheduledDate: Date | null = null;
|
||||
if (dto.scheduledDate) {
|
||||
scheduledDate = new Date(dto.scheduledDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (scheduledDate < today) {
|
||||
throw new BadRequestException(
|
||||
"The scheduled date cannot be in the past.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Same reasoning as the picker filter: a customs-bundled service would put
|
||||
// the booking into the phased customs workflow, which has no contract to
|
||||
// hang off here. Checked server-side because the id comes from the request.
|
||||
if (dto.serviceTypeId) {
|
||||
const serviceType = await this.bookingsRepository.manager
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: dto.serviceTypeId } });
|
||||
if (!serviceType) {
|
||||
throw new NotFoundException(
|
||||
`Service type ${dto.serviceTypeId} not found`,
|
||||
);
|
||||
}
|
||||
if (serviceType.includesCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Shipping lines cannot book a service that bundles customs clearance.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
this.bookingsRepository.save({
|
||||
reference,
|
||||
// The owner columns: a shipping-line booking has no company and no
|
||||
// operational profile, which is exactly what the `chk_bookings_
|
||||
// single_owner` CHECK expects alongside a set shippingLineCompanyId.
|
||||
companyId: null,
|
||||
companyProfileId: null,
|
||||
shippingLineCompanyId: shippingLine.id,
|
||||
status: "AWAITING_DOCUMENTS",
|
||||
bookingType: "ONE_TIME",
|
||||
contractId: null,
|
||||
contractType: "NEW",
|
||||
createdByRole: "SHIPPING_LINE",
|
||||
createdByUserId: userId,
|
||||
// Taken from the chosen route, never from the request body: the
|
||||
// direction is frozen on the route from its yard countries, so
|
||||
// deriving it here keeps it consistent with scheduling and booking
|
||||
// windows, which read the same field.
|
||||
originYardId: route.originYardId,
|
||||
destinationYardId: route.destinationYardId,
|
||||
tradeDirection: route.direction,
|
||||
serviceTypeId: dto.serviceTypeId ?? null,
|
||||
freightType: dto.freightType ?? "CONTAINER",
|
||||
// The shipping line picks its shipment day up front (no later
|
||||
// operation-request step exists for them); cargo is still filled in
|
||||
// when the booking is completed.
|
||||
scheduledDate,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the bookings belonging to the signed-in shipping line, newest first.
|
||||
*
|
||||
* Each row carries `hasQueriedDocuments`: a reviewer querying a document sets
|
||||
* that document's review status but leaves the BOOKING on
|
||||
* DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings
|
||||
* need the shipping line to act. Resolved in one grouped query rather than a
|
||||
* clearance call per row.
|
||||
*/
|
||||
async listMine(userId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const bookings = await this.bookingsRepository.find({
|
||||
where: { shippingLineCompanyId: shippingLine.id },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
if (bookings.length === 0) return [];
|
||||
|
||||
const queried = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
status: "QUERIED",
|
||||
},
|
||||
select: { bookingId: true },
|
||||
});
|
||||
|
||||
const queriedIds = new Set(queried.map((row) => row.bookingId));
|
||||
|
||||
return bookings.map((booking) => ({
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedIds.has(booking.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one of the signed-in shipping line's own bookings. Scoped by owner so
|
||||
* an id belonging to a customer (or another shipping line) reads as missing.
|
||||
*/
|
||||
async findMine(userId: string, bookingId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
// Yards are loaded so the portal can render the lane without a second
|
||||
// lookup — they are set at initiate time from the chosen route. Cargo
|
||||
// (container lines + units, bulk cargo type) rides along for the detail
|
||||
// page's cargo tab once the booking is completed.
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
serviceType: true,
|
||||
cargoType: true,
|
||||
bookingContainers: { containerType: true, units: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// Same flag as the list — see listMine for why booking status alone is not
|
||||
// enough to tell whether the shipping line has something to fix.
|
||||
const queriedCount = await this.bookingsRepository.manager
|
||||
.getRepository(BookingDocumentReview)
|
||||
.count({ where: { bookingId, status: "QUERIED" } });
|
||||
|
||||
// The note Operations wrote when returning the request — the line has to
|
||||
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
|
||||
// exposed; the other review-note types are staff-internal.
|
||||
const changeNote =
|
||||
booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||
? await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.findOne({
|
||||
where: { bookingId, type: "CHANGES_REQUESTED" },
|
||||
order: { createdAt: "DESC" },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...booking,
|
||||
hasQueriedDocuments: queriedCount > 0,
|
||||
operationChangeNote: changeNote?.note ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Train departures dedicated to the signed-in shipping line: schedules whose
|
||||
* `shippingLineCompanyId` is this line's. These trains are hidden from every
|
||||
* customer-facing read, so this endpoint is the ONLY place they surface in
|
||||
* the portal — the home page lists them and the booking detail matches them
|
||||
* to a booking by lane + day.
|
||||
*/
|
||||
async listMyTrains(userId: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
// Recent past kept (48h) so a just-departed train is still visible while
|
||||
// its cargo is on the rails; CANCELLED never shows.
|
||||
const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
const schedules = await this.bookingsRepository.manager
|
||||
.getRepository(TrainSchedule)
|
||||
.find({
|
||||
where: {
|
||||
shippingLineCompanyId: shippingLine.id,
|
||||
status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]),
|
||||
scheduledDepartureDate: MoreThanOrEqual(horizon),
|
||||
},
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
reference: s.reference,
|
||||
trainNumber: s.trainNumber,
|
||||
status: s.status,
|
||||
direction: s.direction,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
scheduledArrivalDate: s.scheduledArrivalDate,
|
||||
originYardId: s.originStationId,
|
||||
originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin",
|
||||
destinationYardId: s.destinationStationId,
|
||||
destinationLabel:
|
||||
s.destinationStation?.label ??
|
||||
s.destinationStation?.code ??
|
||||
"Destination",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one of the signed-in shipping line's own bookings.
|
||||
*
|
||||
* Its own method rather than the customer `customerCancel`: that path routes
|
||||
* into `BookingTransitionService.cancel`, whose status whitelist covers the
|
||||
* contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does
|
||||
* not include the document-clearance statuses a shipping-line booking lives
|
||||
* in — so it would reject every one of them.
|
||||
*
|
||||
* Only allowed before the booking is priced and paid. Once it carries a
|
||||
* charge, cancelling is a billing decision (fees, credit notes) that belongs
|
||||
* with Operations, not a self-service button.
|
||||
*/
|
||||
async cancelMine(userId: string, bookingId: string, reason?: string) {
|
||||
const shippingLine = await this.requireShippingLine(userId);
|
||||
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException("This booking is already cancelled.");
|
||||
}
|
||||
if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) {
|
||||
throw new BadRequestException(
|
||||
"This booking can no longer be cancelled — please contact Operations.",
|
||||
);
|
||||
}
|
||||
// Belt and braces: the statuses above are all pre-pricing, so a charge here
|
||||
// would mean the booking moved on in a way this guard did not anticipate.
|
||||
if (Number(booking.totalAmount ?? 0) > 0) {
|
||||
throw new BadRequestException(
|
||||
"This booking has already been priced — please contact Operations to cancel it.",
|
||||
);
|
||||
}
|
||||
|
||||
// The reason lives on the booking's review-note log, the same place the
|
||||
// customer cancel path records it — there is no column for it.
|
||||
await this.bookingsRepository.manager
|
||||
.getRepository(BookingReviewNote)
|
||||
.save({
|
||||
bookingId,
|
||||
note: reason?.trim() || "Cancelled by the shipping line",
|
||||
type: "REJECTION",
|
||||
authorId: userId,
|
||||
} as never);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
|
||||
return this.findMine(userId, bookingId);
|
||||
}
|
||||
|
||||
/** Mirrors the customer reference format — one booking sequence per year. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const { max } = (await this.bookingsRepository
|
||||
.createQueryBuilder("b")
|
||||
.select(
|
||||
`COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`,
|
||||
"max",
|
||||
)
|
||||
.where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` })
|
||||
.getRawOne<{ max: number }>()) ?? { max: 0 };
|
||||
|
||||
return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import {
|
||||
RegisterShippingLineResponseDto,
|
||||
ShippingLineResponseDto,
|
||||
} from "./dto/shipping-line-response.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Shipping line *companies* — carriers with a portal login, registered by staff
|
||||
* (there is no self-signup). The line receives a single-use activation link and
|
||||
* sets its own password, so staff never see or handle a credential.
|
||||
*
|
||||
* Distinct from `freight.shipping_lines` behind `/shipping-lines`
|
||||
* (rule-engine): that is a pricing lookup list — a code/label a booking points
|
||||
* at via `shipping_line_id` — with no account, no user and no login. Same words,
|
||||
* different concept, hence the separate route.
|
||||
*/
|
||||
@ApiTags("shipping-line-companies")
|
||||
@Controller("shipping-line-companies")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCompaniesController {
|
||||
constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.create)
|
||||
@ApiOperation({
|
||||
summary: "Register a shipping line and send its activation link",
|
||||
})
|
||||
async register(
|
||||
@Body() dto: CreateShippingLineDto,
|
||||
): Promise<RegisterShippingLineResponseDto> {
|
||||
const { shippingLine, activationSentTo } =
|
||||
await this.shippingLineCompaniesService.register(dto);
|
||||
return new RegisterShippingLineResponseDto(shippingLine, activationSentTo);
|
||||
}
|
||||
|
||||
@Get()
|
||||
// OR'd: the credits view needs this list as its line picker, so holding
|
||||
// shipping_line_credits:view alone is enough to read it.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.shippingLines.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
])
|
||||
@ApiOperation({ summary: "List shipping lines (paginated)" })
|
||||
async list(
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
): Promise<{
|
||||
items: ShippingLineResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const result = await this.shippingLineCompaniesService.list(
|
||||
page ? Number(page) : undefined,
|
||||
limit ? Number(limit) : undefined,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item) => new ShippingLineResponseDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||
@ApiOperation({ summary: "Get a shipping line by id" })
|
||||
async findOne(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ShippingLineResponseDto> {
|
||||
return new ShippingLineResponseDto(
|
||||
await this.shippingLineCompaniesService.findById(id),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/resend-activation")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Resend a shipping line's activation / password-reset link",
|
||||
})
|
||||
async resendActivation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
return this.shippingLineCompaniesService.resendActivation(id, dto.channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
import { ShippingLineCreditsController } from "./shipping-line-credits.controller";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Booking is registered here only so this module can create shipping-line
|
||||
// rows in `freight.bookings`; the customer BookingsModule is untouched.
|
||||
TypeOrmModule.forFeature([
|
||||
ShippingLineCompany,
|
||||
ShippingLineCredit,
|
||||
ShippingLineInvoiceApproval,
|
||||
User,
|
||||
Booking,
|
||||
]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
// Credits are billed by generating an ordinary invoice. Billing still knows
|
||||
// nothing about credits and hears about settlement only by emitting its own
|
||||
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
|
||||
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
|
||||
forwardRef(() => BillingModule),
|
||||
],
|
||||
controllers: [
|
||||
ShippingLineCompaniesController,
|
||||
ShippingLineBookingsController,
|
||||
ShippingLineCreditsController,
|
||||
],
|
||||
providers: [
|
||||
ShippingLineCompaniesService,
|
||||
ShippingLineCompaniesRepository,
|
||||
ShippingLineBookingsService,
|
||||
ShippingLineCreditsService,
|
||||
ShippingLineCreditsRepository,
|
||||
ShippingLineInvoiceApprovalsRepository,
|
||||
],
|
||||
// Exported so whatever prices a shipping-line booking can record the charge.
|
||||
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
|
||||
})
|
||||
export class ShippingLineCompaniesModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesRepository extends BaseRepository<ShippingLineCompany> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCompany)
|
||||
private readonly shippingLineRepo: Repository<ShippingLineCompany>,
|
||||
) {
|
||||
super(shippingLineRepo);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineRepo.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
/** Case-insensitive, matching the `lower(email)` unique index. */
|
||||
async existsByEmail(email: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("lower(sl.email) = lower(:email)", { email })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsByScac(scacCode: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("upper(sl.scacCode) = upper(:scacCode)", { scacCode })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findAllPaginated(
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[ShippingLineCompany[], number]> {
|
||||
return this.shippingLineRepo.findAndCount({
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert inside a caller-supplied transaction, so the shipping-line row and
|
||||
* the IAM user it points at commit together — a row referencing a user that
|
||||
* was rolled back (or vice versa) is an account nobody can sign in to.
|
||||
*/
|
||||
createInTransaction(
|
||||
manager: EntityManager,
|
||||
data: Partial<ShippingLineCompany>,
|
||||
): Promise<ShippingLineCompany> {
|
||||
const repo = manager.getRepository(ShippingLineCompany);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { ConflictException } from "@nestjs/common";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Registration is the whole feature: an IAM account and a carrier record
|
||||
* created together, then an activation link the line uses to set its own
|
||||
* password. These lock the parts that would silently break the login.
|
||||
*/
|
||||
describe("ShippingLineCompaniesService.register", () => {
|
||||
const savedUser = { id: "user-1" };
|
||||
|
||||
let shippingLinesRepo: {
|
||||
existsByEmail: jest.Mock;
|
||||
existsByScac: jest.Mock;
|
||||
createInTransaction: jest.Mock;
|
||||
findById: jest.Mock;
|
||||
findByUserId: jest.Mock;
|
||||
};
|
||||
let userRepository: { findOne: jest.Mock };
|
||||
let customerResetService: { sendResetLinkToUser: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
|
||||
let service: ShippingLineCompaniesService;
|
||||
|
||||
const dto = {
|
||||
name: "Ethiopian Shipping Lines",
|
||||
email: "Ops@ESL.com.et",
|
||||
phoneNumber: "+251911223344",
|
||||
scacCode: "eslk",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepoInTx = {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn().mockResolvedValue(savedUser),
|
||||
};
|
||||
|
||||
shippingLinesRepo = {
|
||||
existsByEmail: jest.fn().mockResolvedValue(false),
|
||||
existsByScac: jest.fn().mockResolvedValue(false),
|
||||
createInTransaction: jest
|
||||
.fn()
|
||||
.mockImplementation((_m, data) => ({ id: "sl-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
userRepository = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
customerResetService = {
|
||||
sendResetLinkToUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) =>
|
||||
cb({ getRepository: () => userRepoInTx }),
|
||||
),
|
||||
};
|
||||
|
||||
service = new ShippingLineCompaniesService(
|
||||
shippingLinesRepo as never,
|
||||
userRepository as never,
|
||||
customerResetService as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the IAM account with no password set", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const created = userRepoInTx.create.mock.calls[0][0];
|
||||
expect(created).toMatchObject({
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
// The line sets its own password from the activation link. Employee
|
||||
// creation seeds a shared default here; a shipping line must not get one.
|
||||
hasSetPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never writes a credential row", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
// Only the User repository is touched inside the transaction — a
|
||||
// UserCredential insert would mean the account has a password nobody chose.
|
||||
for (const call of userRepoInTx.save.mock.calls) {
|
||||
expect(call[0]).not.toHaveProperty("password");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalises email and SCAC before storing", async () => {
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
expect(result.shippingLine).toMatchObject({
|
||||
email: "ops@esl.com.et",
|
||||
scacCode: "ESLK",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the account and the record in one transaction", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the activation link outside the transaction, after commit", async () => {
|
||||
const order: string[] = [];
|
||||
dataSource.transaction.mockImplementation(async (cb: never) => {
|
||||
order.push("tx");
|
||||
return (cb as unknown as (m: unknown) => Promise<unknown>)({
|
||||
getRepository: () => userRepoInTx,
|
||||
});
|
||||
});
|
||||
customerResetService.sendResetLinkToUser.mockImplementation(async () => {
|
||||
order.push("send");
|
||||
return { maskedTarget: "o**@esl.com.et", channel: "email" };
|
||||
});
|
||||
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(order[0]).toBe("tx");
|
||||
expect(order).toContain("send");
|
||||
});
|
||||
|
||||
it("emails the link, and also texts it when the number is domestic", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toContain(ResetChannel.Email);
|
||||
expect(channels).toContain(ResetChannel.Phone);
|
||||
});
|
||||
|
||||
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
|
||||
await service.register({ ...dto, phoneNumber: "+441234567890" } as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toEqual([ResetChannel.Email]);
|
||||
});
|
||||
|
||||
it("keeps the registration when the activation link fails to send", async () => {
|
||||
customerResetService.sendResetLinkToUser.mockResolvedValue(null);
|
||||
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
// The account is valid without the link and the link is resendable —
|
||||
// a delivery failure must not roll back the registration.
|
||||
expect(result.shippingLine).toMatchObject({ id: "sl-1" });
|
||||
expect(result.activationSentTo).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a duplicate email", async () => {
|
||||
shippingLinesRepo.existsByEmail.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a duplicate SCAC", async () => {
|
||||
shippingLinesRepo.existsByScac.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses credentials already belonging to another account", async () => {
|
||||
// Reusing an existing IAM user would let one login resolve to both a
|
||||
// customer and a shipping line.
|
||||
userRepository.findOne.mockResolvedValue({ id: "existing" });
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults the username to the email", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({
|
||||
username: "ops@esl.com.et",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The default reset lookup inner-joins an active `user_credentials` row so a
|
||||
* reset cannot revive a suspended account. A shipping line has no credential
|
||||
* until it uses the activation link, so without this flag the account is
|
||||
* excluded from its own activation — the link is never minted, never logged,
|
||||
* and resend answers 404.
|
||||
*/
|
||||
it("requests the credential-less lookup for every activation send", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled();
|
||||
for (const call of customerResetService.sendResetLinkToUser.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ allowWithoutCredential: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requests the credential-less lookup when resending", async () => {
|
||||
shippingLinesRepo.findById.mockResolvedValue({
|
||||
id: "sl-1",
|
||||
userId: "user-1",
|
||||
phoneNumber: "+251911223344",
|
||||
});
|
||||
|
||||
await service.resendActivation("sl-1", ResetChannel.Email);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
ResetChannel.Email,
|
||||
expect.objectContaining({ allowWithoutCredential: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
|
||||
export interface RegisteredShippingLine {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/** Masked destination of the activation link, or null if none was sent. */
|
||||
activationSentTo: string | null;
|
||||
activationChannel: ResetChannel | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesService {
|
||||
private readonly logger = new Logger(ShippingLineCompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a shipping line: create its IAM account and its record together,
|
||||
* then send an activation link so the line sets its own password.
|
||||
*
|
||||
* The IAM mechanics follow `BackofficeService.createOrganizationUser` — same
|
||||
* entities, same transaction shape — with one deliberate difference: no
|
||||
* `UserCredential` row is written and `hasSetPassword` stays false. Staff
|
||||
* creating an employee seed a shared default password; a shipping line must
|
||||
* come through the activation link instead, so no credential exists until the
|
||||
* line sets one.
|
||||
*/
|
||||
async register(dto: CreateShippingLineDto): Promise<RegisteredShippingLine> {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = (dto.username?.trim() || email).toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
const scacCode = dto.scacCode?.trim().toUpperCase();
|
||||
|
||||
if (await this.shippingLineCompaniesRepo.existsByEmail(email)) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with SCAC ${scacCode} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
// An existing IAM account means these credentials already belong to a
|
||||
// customer or an employee. Reusing it would let one login resolve to two
|
||||
// different account kinds, so this is refused rather than merged — unlike
|
||||
// employee creation, which legitimately re-uses a person's existing user.
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException(
|
||||
"email_or_username_already_in_use",
|
||||
);
|
||||
}
|
||||
|
||||
const shippingLine = await this.dataSource.transaction(async (manager) => {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
name: { en: dto.name.trim() },
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
// No credential row is written: the account has no password until the
|
||||
// activation link is used. `hasSetPassword` must stay false or the
|
||||
// portal treats the account as ready to sign in with a password that
|
||||
// does not exist.
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.shippingLineCompaniesRepo.createInTransaction(manager, {
|
||||
userId: user.id as string,
|
||||
name: dto.name.trim(),
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
scacCode: scacCode ?? null,
|
||||
imoNumber: dto.imoNumber?.trim() || null,
|
||||
bicCode: dto.bicCode?.trim() || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||
// a registered line. The link is resendable, and the account is already
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(shippingLine);
|
||||
|
||||
return {
|
||||
shippingLine,
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the activation link on registration.
|
||||
*
|
||||
* Email always goes out — it is required at registration and is the only
|
||||
* channel guaranteed to reach a foreign-registered line. SMS is sent in
|
||||
* addition when the number is domestic, since the gateway silently drops
|
||||
* anything else (see `CustomerResetService`). Two links are two independent
|
||||
* single-use tickets; whichever the line opens first works.
|
||||
*
|
||||
* Reports the email send, as that is the one that is always attempted.
|
||||
*/
|
||||
async sendActivationLink(shippingLine: ShippingLineCompany) {
|
||||
const scope = `shipping line ${shippingLine.id}`;
|
||||
|
||||
const emailed = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
ResetChannel.Email,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
if (!emailed) {
|
||||
this.logger.error(
|
||||
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
|
||||
const texted = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
ResetChannel.Phone,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
if (!texted) {
|
||||
this.logger.warn(
|
||||
`Activation SMS not sent for shipping line ${shippingLine.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return emailed;
|
||||
}
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
|
||||
if (
|
||||
channel === ResetChannel.Phone &&
|
||||
(!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This shipping line has no domestic phone number — the SMS gateway cannot reach it",
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
channel,
|
||||
{ scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active account with ${
|
||||
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||
} for this shipping line`,
|
||||
);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ShippingLineCompany> {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/** The shipping line signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineCompaniesRepo.findByUserId(userId);
|
||||
}
|
||||
|
||||
async list(page = 1, limit = 20) {
|
||||
const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated(
|
||||
(page - 1) * limit,
|
||||
limit,
|
||||
);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import {
|
||||
CancelCreditDto,
|
||||
DecideInvoiceActionDto,
|
||||
GenerateCreditInvoiceDto,
|
||||
RequestInvoiceActionDto,
|
||||
} from "./dto/shipping-line-credit.dto";
|
||||
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe.
|
||||
*
|
||||
* A shipping line books and ships without paying — the charge is recorded as a
|
||||
* credit instead. Finance reads the unbilled list here, batches it into an
|
||||
* invoice, and the line then pays that invoice through the ordinary
|
||||
* `/billing` + CBE routes; nothing in this controller touches money directly.
|
||||
*/
|
||||
@ApiTags("shipping-line-credits")
|
||||
@Controller("shipping-line-credits")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCreditsController {
|
||||
constructor(private readonly credits: ShippingLineCreditsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
|
||||
})
|
||||
async listAll(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listAll(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "summary" is never
|
||||
// captured as a shipping-line id.
|
||||
@Get("summary")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
|
||||
})
|
||||
async summaryAll(
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.summary(shippingLineId);
|
||||
}
|
||||
|
||||
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
|
||||
@Get("invoices")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
|
||||
})
|
||||
async listInvoices(
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
|
||||
shippingLineId?: string,
|
||||
) {
|
||||
return this.credits.listCreditInvoices(
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status as Freight.InvoiceStatus | undefined,
|
||||
shippingLineId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("invoice-actions/pending")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
|
||||
})
|
||||
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
|
||||
const ids = (invoiceIds ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
return this.credits.pendingInvoiceActions(ids);
|
||||
}
|
||||
|
||||
// ── Maker–checker on credit invoices ──────────────────────────────────────
|
||||
// Request and approve are DIFFERENT permissions, and the service refuses a
|
||||
// decision by the requester — marking debt paid or voiding an invoice is
|
||||
// never a one-person action.
|
||||
|
||||
@Post("invoices/:invoiceId/mark-paid-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
|
||||
})
|
||||
async requestMarkPaid(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.MarkPaid,
|
||||
user.id,
|
||||
dto.reason,
|
||||
dto.paymentReference,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoices/:invoiceId/cancel-request")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
|
||||
})
|
||||
async requestCancel(
|
||||
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
|
||||
@Body() dto: RequestInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.requestInvoiceAction(
|
||||
invoiceId,
|
||||
ShippingLineInvoiceActionType.Cancel,
|
||||
user.id,
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
|
||||
})
|
||||
async approveInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
true,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice-actions/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending invoice request — nothing is changed.",
|
||||
})
|
||||
async rejectInvoiceAction(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: DecideInvoiceActionDto,
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
) {
|
||||
return this.credits.decideInvoiceAction(
|
||||
approvalId,
|
||||
user.id,
|
||||
false,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared before the parameterised staff routes so "me" is never captured
|
||||
// as a shipping-line id.
|
||||
@Get("me")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The signed-in shipping line's own statement: outstanding balance plus its credit ledger.",
|
||||
})
|
||||
async myStatement(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.credits.myStatement(
|
||||
user.id,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/outstanding")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"What one shipping line owes: unbilled + billed totals, derived from the ledger.",
|
||||
})
|
||||
async outstanding(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.outstanding(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId/unbilled")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Credits that can go on an invoice for this line, oldest first. This is the selection list.",
|
||||
})
|
||||
async listUnbilled(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
) {
|
||||
return this.credits.listUnbilled(shippingLineId);
|
||||
}
|
||||
|
||||
@Get(":shippingLineId")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
|
||||
@ApiOperation({
|
||||
summary: "Full credit ledger for one shipping line (paginated).",
|
||||
})
|
||||
async listCredits(
|
||||
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("status") status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
return this.credits.listCredits(
|
||||
shippingLineId,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("invoice")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.",
|
||||
})
|
||||
async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) {
|
||||
return this.credits.generateInvoice(dto.creditIds, {
|
||||
dueInDays: dto.dueInDays,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":creditId/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Write off an unbilled credit. Once billed, cancel the invoice instead.",
|
||||
})
|
||||
async cancel(
|
||||
@Param("creditId", ParseUUIDPipe) creditId: string,
|
||||
@Body() dto: CancelCreditDto,
|
||||
) {
|
||||
return this.credits.cancelCredit(creditId, dto.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
OUTSTANDING_CREDIT_STATUSES,
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
/** Priced but not yet on an invoice. */
|
||||
unbilledAmount: number;
|
||||
/** On an issued invoice, awaiting payment. */
|
||||
billedAmount: number;
|
||||
/** `unbilledAmount + billedAmount` — the full debt. */
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCredit> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCredit)
|
||||
private readonly credits: Repository<ShippingLineCredit>,
|
||||
) {
|
||||
super(credits);
|
||||
}
|
||||
|
||||
findByBookingId(bookingId: string): Promise<ShippingLineCredit | null> {
|
||||
return this.credits.findOne({ where: { bookingId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's worklist: everything for one line that can go on an invoice,
|
||||
* oldest first so the longest-standing debt is billed before newer charges.
|
||||
*/
|
||||
findUnbilled(shippingLineCompanyId: string): Promise<ShippingLineCredit[]> {
|
||||
return this.credits.find({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
},
|
||||
relations: { booking: true },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByInvoiceId(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
const repo = manager
|
||||
? manager.getRepository(ShippingLineCredit)
|
||||
: this.credits;
|
||||
return repo.find({ where: { invoiceId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific batch inside the caller's transaction and lock it, so two
|
||||
* concurrent invoice generations cannot both claim the same credits.
|
||||
*/
|
||||
findByIdsForUpdate(
|
||||
manager: EntityManager,
|
||||
ids: string[],
|
||||
): Promise<ShippingLineCredit[]> {
|
||||
return manager.getRepository(ShippingLineCredit).find({
|
||||
where: { id: In(ids) },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived debt — never a stored column. Grouped in one query so the detail
|
||||
* page does not fan out per status. Without a line id it totals every line —
|
||||
* the back-office overview figure.
|
||||
*/
|
||||
async outstandingFor(
|
||||
shippingLineCompanyId?: string,
|
||||
): Promise<OutstandingTotals> {
|
||||
const qb = this.credits
|
||||
.createQueryBuilder("credit")
|
||||
.select("credit.status", "status")
|
||||
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("credit.status IN (:...statuses)", {
|
||||
statuses: [...OUTSTANDING_CREDIT_STATUSES],
|
||||
})
|
||||
.andWhere("credit.deletedAt IS NULL")
|
||||
.groupBy("credit.status");
|
||||
if (shippingLineCompanyId) {
|
||||
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||
shippingLineCompanyId,
|
||||
});
|
||||
}
|
||||
const rows = await qb.getRawMany<{
|
||||
status: string;
|
||||
amount: string;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
const totals = (status: ShippingLineCreditStatus) => {
|
||||
const row = rows.find((r) => r.status === status);
|
||||
return {
|
||||
amount: row ? Number(row.amount) : 0,
|
||||
count: row ? Number(row.count) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const unbilled = totals(ShippingLineCreditStatus.Unbilled);
|
||||
const billed = totals(ShippingLineCreditStatus.Billed);
|
||||
|
||||
return {
|
||||
unbilledAmount: unbilled.amount,
|
||||
billedAmount: billed.amount,
|
||||
totalOutstanding: unbilled.amount + billed.amount,
|
||||
unbilledCount: unbilled.count,
|
||||
billedCount: billed.count,
|
||||
currency: "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated ledger — every credit, whatever its status. Scoped to one line
|
||||
* when an id is given, across all lines otherwise.
|
||||
*/
|
||||
findAllPaginated(
|
||||
shippingLineCompanyId: string | undefined,
|
||||
skip: number,
|
||||
take: number,
|
||||
status?: ShippingLineCreditStatus,
|
||||
): Promise<[ShippingLineCredit[], number]> {
|
||||
return this.credits.findAndCount({
|
||||
where: {
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
relations: { booking: true, invoice: true, shippingLineCompany: true },
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
||||
|
||||
/**
|
||||
* The money path: a shipping line ships without paying, so the debt lives
|
||||
* entirely in these three transitions. Each test below locks one way the debt
|
||||
* could be lost or double-counted.
|
||||
*/
|
||||
describe("ShippingLineCreditsService", () => {
|
||||
let creditsRepo: {
|
||||
findByIdsForUpdate: jest.Mock;
|
||||
findUnbilled: jest.Mock;
|
||||
outstandingFor: jest.Mock;
|
||||
findAllPaginated: jest.Mock;
|
||||
};
|
||||
let billing: { generateInvoice: jest.Mock };
|
||||
let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock; getRepository: jest.Mock };
|
||||
let mg: {
|
||||
findOne: jest.Mock;
|
||||
getRepository: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let updateResult: { affected: number };
|
||||
let service: ShippingLineCreditsService;
|
||||
|
||||
const booking = {
|
||||
id: "booking-1",
|
||||
reference: "BK-2026-000001",
|
||||
shippingLineCompanyId: "sl-1",
|
||||
} as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
txRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ id: "credit-1", ...v })),
|
||||
};
|
||||
mg = {
|
||||
findOne: jest.fn().mockResolvedValue(booking),
|
||||
getRepository: jest.fn(() => txRepo),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
updateResult = { affected: 2 };
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) => cb(mg)),
|
||||
getRepository: jest.fn(() => ({
|
||||
update: jest.fn().mockResolvedValue(updateResult),
|
||||
})),
|
||||
};
|
||||
creditsRepo = {
|
||||
findByIdsForUpdate: jest.fn(),
|
||||
findUnbilled: jest.fn(),
|
||||
outstandingFor: jest.fn(),
|
||||
findAllPaginated: jest.fn(),
|
||||
};
|
||||
billing = {
|
||||
generateInvoice: jest.fn().mockResolvedValue({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
totalAmount: 50000,
|
||||
}),
|
||||
};
|
||||
shippingLines = {
|
||||
findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
service = new ShippingLineCreditsService(
|
||||
dataSource as never,
|
||||
creditsRepo as never,
|
||||
// Approvals repo — only the invoice maker–checker paths touch it.
|
||||
{
|
||||
findPendingByInvoice: jest.fn(),
|
||||
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
|
||||
findByIdForUpdate: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as never,
|
||||
billing as never,
|
||||
shippingLines as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("recordCredit", () => {
|
||||
it("records the charge against the booking's own shipping line", async () => {
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(txRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// Taken from the booking, never from the caller.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
expect(credit.id).toBe("credit-1");
|
||||
});
|
||||
|
||||
it("is idempotent per booking — a retried pricing step cannot double the debt", async () => {
|
||||
const existing = {
|
||||
id: "credit-existing",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
amount: 20000,
|
||||
currency: "ETB",
|
||||
};
|
||||
txRepo.findOne.mockResolvedValue(existing);
|
||||
|
||||
const credit = await service.recordCredit({
|
||||
bookingId: "booking-1",
|
||||
amount: 20000,
|
||||
});
|
||||
|
||||
expect(credit).toBe(existing);
|
||||
expect(txRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a customer booking — those are paid up front, not on credit", async () => {
|
||||
mg.findOne.mockResolvedValue({
|
||||
...booking,
|
||||
shippingLineCompanyId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: 100 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects a negative amount", async () => {
|
||||
await expect(
|
||||
service.recordCredit({ bookingId: "booking-1", amount: -1 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateInvoice", () => {
|
||||
const unbilled = (id: string, amount: number) => ({
|
||||
id,
|
||||
shippingLineCompanyId: "sl-1",
|
||||
bookingId: `booking-${id}`,
|
||||
amount,
|
||||
currency: "ETB",
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
description: `Freight service — ${id}`,
|
||||
});
|
||||
|
||||
it("bills the batch as one invoice and flips the credits to BILLED", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
unbilled("c2", 30000),
|
||||
]);
|
||||
|
||||
const invoice = await service.generateInvoice(["c1", "c2"]);
|
||||
|
||||
expect(billing.generateInvoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// The payer, not a customer — invoices.company_id stays null.
|
||||
shippingLineCompanyId: "sl-1",
|
||||
sourceId: "sl-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
lines: [
|
||||
expect.objectContaining({ amount: 20000 }),
|
||||
expect.objectContaining({ amount: 30000 }),
|
||||
],
|
||||
}),
|
||||
mg,
|
||||
);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
}),
|
||||
);
|
||||
expect(invoice.id).toBe("inv-1");
|
||||
});
|
||||
|
||||
it("refuses to bill a credit that is already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed },
|
||||
]);
|
||||
|
||||
await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix two shipping lines on one invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(billing.generateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to mix currencies", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
unbilled("c1", 20000),
|
||||
{ ...unbilled("c2", 300), currency: "USD" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "c2"]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("reports ids that do not exist rather than silently billing the rest", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]);
|
||||
|
||||
await expect(
|
||||
service.generateInvoice(["c1", "missing"]),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("rejects an empty selection", async () => {
|
||||
await expect(service.generateInvoice([])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInvoicePaid", () => {
|
||||
it("clears every billed credit on the settled invoice", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 2 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-20260813-00001",
|
||||
} as never);
|
||||
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
// Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt.
|
||||
{ invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed },
|
||||
expect.objectContaining({ status: ShippingLineCreditStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op on webhook redelivery", async () => {
|
||||
const update = jest.fn().mockResolvedValue({ affected: 0 });
|
||||
dataSource.getRepository = jest.fn(() => ({ update }));
|
||||
|
||||
await expect(
|
||||
service.onInvoicePaid({
|
||||
invoiceId: "inv-1",
|
||||
invoiceNumber: "INV-1",
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelCredit", () => {
|
||||
it("writes off an unbilled credit", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{ id: "c1", status: ShippingLineCreditStatus.Unbilled },
|
||||
]);
|
||||
|
||||
const result = await service.cancelCredit("c1", "Booking voided");
|
||||
|
||||
expect(result.status).toBe(ShippingLineCreditStatus.Cancelled);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
ShippingLineCredit,
|
||||
{ id: "c1" },
|
||||
expect.objectContaining({
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancellationReason: "Booking voided",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to write off a credit already on an invoice", async () => {
|
||||
creditsRepo.findByIdsForUpdate.mockResolvedValue([
|
||||
{
|
||||
id: "c1",
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: "inv-1",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.cancelCredit("c1", "oops"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,780 @@
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "./entities/shipping-line-credit.entity";
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
ShippingLineInvoiceActionType,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/** A charge to record against a shipping line's booking. */
|
||||
export interface RecordCreditInput {
|
||||
bookingId: string;
|
||||
/** Frozen at this value; never recalculated afterwards. */
|
||||
amount: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Payment terms for a generated shipping-line invoice. */
|
||||
export interface GenerateCreditInvoiceOptions {
|
||||
/** Pay window in days; defaults to the billing module's own default. */
|
||||
dueInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by the booking-transition accept path for shipping-line bookings.
|
||||
* An event rather than a service call: BookingsModule cannot import the
|
||||
* shipping-line modules without closing a module cycle.
|
||||
*/
|
||||
export interface ShippingLineBookingAcceptedPayload {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
/** The booking's priced total, frozen at completion time. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
*
|
||||
* Three moments, in order:
|
||||
*
|
||||
* 1. **Charge.** A shipping line's booking is priced, and
|
||||
* {@link recordCredit} writes an UNBILLED credit. No invoice, no payment
|
||||
* intent, no gate on the booking — it proceeds regardless.
|
||||
* 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and
|
||||
* {@link generateInvoice} turns them into a single invoice, one line per
|
||||
* credit. The credits become BILLED.
|
||||
* 3. **Settle.** The line pays that invoice through the ordinary CBE flow.
|
||||
* Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid}
|
||||
* marks the batch PAID, and the debt disappears.
|
||||
*
|
||||
* Nothing here decrements a balance: the amount owed is always
|
||||
* `SUM(amount)` over non-terminal credits. Payment is settled by the gateway
|
||||
* webhook alone — no manual approval step — so a credit only ever leaves debt
|
||||
* because real money arrived.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShippingLineCreditsService {
|
||||
private readonly logger = new Logger(ShippingLineCreditsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly credits: ShippingLineCreditsRepository,
|
||||
private readonly approvals: ShippingLineInvoiceApprovalsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly shippingLines: ShippingLineCompaniesService,
|
||||
) {}
|
||||
|
||||
// ── 1. Charge ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record what a shipping line owes for one booking.
|
||||
*
|
||||
* Called when the booking is priced. The owner is read off the booking
|
||||
* itself rather than passed in, so a credit can never be filed against the
|
||||
* wrong line. Idempotent per booking: a second call returns the existing
|
||||
* credit untouched rather than doubling the debt — safe against a retried
|
||||
* pricing step, and the partial unique index backs it at the DB level.
|
||||
*
|
||||
* Pass `manager` to enlist in the caller's transaction, so the credit and
|
||||
* whatever priced the booking commit together.
|
||||
*/
|
||||
async recordCredit(
|
||||
input: RecordCreditInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<ShippingLineCredit> {
|
||||
if (!(input.amount >= 0)) {
|
||||
throw new BadRequestException("Credit amount cannot be negative.");
|
||||
}
|
||||
|
||||
const run = async (mg: EntityManager): Promise<ShippingLineCredit> => {
|
||||
const booking = await mg.findOne(Booking, {
|
||||
where: { id: input.bookingId },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${input.bookingId} not found`);
|
||||
}
|
||||
if (!booking.shippingLineCompanyId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`,
|
||||
);
|
||||
}
|
||||
|
||||
const repo = mg.getRepository(ShippingLineCredit);
|
||||
const existing = await repo.findOne({
|
||||
where: { bookingId: input.bookingId },
|
||||
});
|
||||
if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) {
|
||||
this.logger.warn(
|
||||
`Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const credit = await repo.save(
|
||||
repo.create({
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
bookingId: input.bookingId,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? "ETB",
|
||||
description:
|
||||
input.description ?? `Freight service — booking ${booking.reference}`,
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
}),
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
creditId: credit.id,
|
||||
bookingId: credit.bookingId,
|
||||
shippingLineCompanyId: credit.shippingLineCompanyId,
|
||||
amount: credit.amount,
|
||||
},
|
||||
{ path: "shippingLineCredit.recorded" },
|
||||
);
|
||||
|
||||
return credit;
|
||||
};
|
||||
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a shipping-line booking becomes debt: Operations accepted it.
|
||||
* Swallows its own failures with a loud log instead of throwing — the accept
|
||||
* has already committed, and failing the staff response for a ledger write
|
||||
* would present a succeeded accept as an error. `recordCredit` is idempotent
|
||||
* per booking, so a re-accepted (previously reverted) booking cannot double
|
||||
* the debt.
|
||||
*/
|
||||
@OnEvent("shipping_line_booking.accepted")
|
||||
async onBookingAccepted(
|
||||
payload: ShippingLineBookingAcceptedPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.recordCredit({
|
||||
bookingId: payload.bookingId,
|
||||
amount: payload.amount,
|
||||
// Shipping lines are always billed in ETB (enforced at completion).
|
||||
currency: "ETB",
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Bill ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Turn a batch of unbilled credits into one invoice.
|
||||
*
|
||||
* Every credit must belong to the SAME shipping line — one invoice has one
|
||||
* payer, so a mixed batch is rejected rather than silently split. The whole
|
||||
* thing runs in one transaction with the credits locked FOR UPDATE, so two
|
||||
* finance users clicking at once cannot bill the same credit twice: the
|
||||
* second transaction blocks, then finds the rows already BILLED and fails.
|
||||
*/
|
||||
async generateInvoice(
|
||||
creditIds: string[],
|
||||
options: GenerateCreditInvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
if (creditIds.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Select at least one credit to invoice.",
|
||||
);
|
||||
}
|
||||
const uniqueIds = [...new Set(creditIds)];
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds);
|
||||
|
||||
const missing = uniqueIds.filter(
|
||||
(id) => !credits.some((c) => c.id === id),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new NotFoundException(
|
||||
`Credit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alreadyBilled = credits.filter(
|
||||
(c) => c.status !== ShippingLineCreditStatus.Unbilled,
|
||||
);
|
||||
if (alreadyBilled.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled
|
||||
.map((c) => `${c.id} (${c.status})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId));
|
||||
if (lineIds.size > 1) {
|
||||
throw new BadRequestException(
|
||||
"All selected credits must belong to the same shipping line — one invoice has one payer.",
|
||||
);
|
||||
}
|
||||
const shippingLineCompanyId = credits[0].shippingLineCompanyId;
|
||||
|
||||
const currencies = new Set(credits.map((c) => c.currency));
|
||||
if (currencies.size > 1) {
|
||||
throw new BadRequestException(
|
||||
`Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const currency = credits[0].currency;
|
||||
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: InvoiceLineInput[] = credits.map((credit) => ({
|
||||
chargeType: "SHIPPING_LINE_SERVICE",
|
||||
description: credit.description ?? undefined,
|
||||
quantity: 1,
|
||||
unitRate: Number(credit.amount),
|
||||
amount: Number(credit.amount),
|
||||
currency: credit.currency,
|
||||
metadata: { creditId: credit.id, bookingId: credit.bookingId },
|
||||
}));
|
||||
|
||||
const invoice = await this.billing.generateInvoice(
|
||||
{
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
// Unlike other sources this is the payer, not a single billed
|
||||
// record: the invoice spans many bookings, and each credit keeps its
|
||||
// own booking link.
|
||||
sourceId: shippingLineCompanyId,
|
||||
type: "SHIPPING_LINE_CREDIT",
|
||||
shippingLineCompanyId,
|
||||
currency,
|
||||
lines,
|
||||
dueInDays: options.dueInDays,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
},
|
||||
mg,
|
||||
);
|
||||
|
||||
const billedAt = new Date();
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: In(credits.map((c) => c.id)) },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
invoiceId: invoice.id,
|
||||
billedAt,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
shippingLineCompanyId,
|
||||
creditCount: credits.length,
|
||||
totalAmount: invoice.totalAmount,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiced" },
|
||||
);
|
||||
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Settle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear the batch once its invoice is paid.
|
||||
*
|
||||
* Driven by the billing event rather than a call inside the payment path, so
|
||||
* the CBE webhook flow needs no knowledge of credits: whatever settles the
|
||||
* invoice — gateway webhook, or a finance-recorded offline payment — this
|
||||
* fires. Idempotent, because a redelivered webhook re-emits the event.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.paid")
|
||||
async onInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{ status: ShippingLineCreditStatus.Paid, paidAt: new Date() },
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsCleared: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.settled" },
|
||||
);
|
||||
|
||||
// Zero is the ordinary idempotent no-op on webhook redelivery. It is only
|
||||
// worth a line in the log, not an error: the invoice is paid either way.
|
||||
if (!result.affected) {
|
||||
this.logger.log(
|
||||
`Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Finance's worklist: what can go on an invoice for this line right now. */
|
||||
async listUnbilled(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const credits = await this.credits.findUnbilled(shippingLineCompanyId);
|
||||
return {
|
||||
items: credits,
|
||||
totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0),
|
||||
currency: credits[0]?.currency ?? "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/** The debt figure shown on the shipping-line detail page. */
|
||||
async outstanding(shippingLineCompanyId: string) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-office overview: outstanding totals across every line, or one line
|
||||
* when an id is given.
|
||||
*/
|
||||
async summary(shippingLineCompanyId?: string) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
return this.credits.outstandingFor(shippingLineCompanyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole ledger across every shipping line, newest first — finance's
|
||||
* landing list. Optionally narrowed to one line and/or one status.
|
||||
*/
|
||||
async listAll(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
if (shippingLineCompanyId) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
}
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** Full ledger for one line, newest first. */
|
||||
async listCredits(
|
||||
shippingLineCompanyId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: ShippingLineCreditStatus,
|
||||
) {
|
||||
await this.requireShippingLine(shippingLineCompanyId);
|
||||
const [items, total] = await this.credits.findAllPaginated(
|
||||
shippingLineCompanyId,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
status,
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in shipping line's own statement: what it owes and why.
|
||||
* Resolves the line from the session, so one line can never read another's.
|
||||
*/
|
||||
async myStatement(userId: string, page = 1, pageSize = 20) {
|
||||
const shippingLine = await this.shippingLines.findByUserId(userId);
|
||||
if (!shippingLine) {
|
||||
throw new ForbiddenException("This account is not a shipping line.");
|
||||
}
|
||||
const [outstanding, ledger] = await Promise.all([
|
||||
this.credits.outstandingFor(shippingLine.id),
|
||||
this.credits.findAllPaginated(
|
||||
shippingLine.id,
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
outstanding,
|
||||
items: ledger[0],
|
||||
total: ledger[1],
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Credit invoices: list + maker–checker manual actions ─────────────────
|
||||
|
||||
/**
|
||||
* Staff list of the invoices minted from credit batches, each with its line
|
||||
* name and any undecided manual-action request attached — the data the
|
||||
* back-office actions column renders from.
|
||||
*/
|
||||
async listCreditInvoices(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status?: Freight.InvoiceStatus,
|
||||
shippingLineCompanyId?: string,
|
||||
) {
|
||||
const [invoices, total] = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findAndCount({
|
||||
where: {
|
||||
source: Freight.InvoiceSource.ShippingLineCredit,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const lineIds = [
|
||||
...new Set(
|
||||
invoices
|
||||
.map((inv) => inv.shippingLineCompanyId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const lines = lineIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.find({ where: { id: In(lineIds) } })
|
||||
: [];
|
||||
const nameById = new Map(lines.map((l) => [l.id, l.name]));
|
||||
|
||||
const pending = await this.approvals.findPendingByInvoiceIds(
|
||||
invoices.map((inv) => inv.id),
|
||||
);
|
||||
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
|
||||
|
||||
return {
|
||||
items: invoices.map((inv) => ({
|
||||
...inv,
|
||||
shippingLineName: inv.shippingLineCompanyId
|
||||
? (nameById.get(inv.shippingLineCompanyId) ?? null)
|
||||
: null,
|
||||
pendingAction: pendingByInvoice.get(inv.id) ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Undecided requests for a batch of invoices — feeds any invoice list. */
|
||||
async pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
// Bounded to a list page's worth of ids; anything larger is a misuse.
|
||||
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance raises a manual action on a credit invoice: record an offline
|
||||
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
|
||||
* yet — a chief with the matching approve permission decides it. One
|
||||
* undecided request per invoice (backed by a partial unique index).
|
||||
*/
|
||||
async requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: ShippingLineInvoiceActionType,
|
||||
requestedBy: string,
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
|
||||
throw new BadRequestException(
|
||||
"Manual actions here apply only to shipping-line credit invoices.",
|
||||
);
|
||||
}
|
||||
// Fast feedback only — the billing service re-validates authoritatively
|
||||
// (under lock) when the request is approved.
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.MarkPaid &&
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
) {
|
||||
throw new BadRequestException("Invoice is already paid.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Invoice is already cancelled.");
|
||||
}
|
||||
if (
|
||||
action === ShippingLineInvoiceActionType.Cancel &&
|
||||
Number(invoice.paidAmount) > 0
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
const existing = await this.approvals.findPendingByInvoice(invoiceId);
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`A ${existing.action} request is already awaiting decision on this invoice.`,
|
||||
);
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
invoiceId,
|
||||
action,
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
requestedBy,
|
||||
reason,
|
||||
paymentReference: paymentReference ?? null,
|
||||
});
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId: approval.id,
|
||||
invoiceId,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
action,
|
||||
requestedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.requested" },
|
||||
);
|
||||
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Gated purely by permission (the approve/reject
|
||||
* grants on the controller routes) — a decider holding the grant may decide
|
||||
* ANY pending request, their own included; that trade-off is deliberate.
|
||||
*
|
||||
* Approval executes the real action through the billing service AFTER the
|
||||
* decision row commits — its settlement/cancellation events must fire from
|
||||
* billing's own committed transaction (the credits listeners react to
|
||||
* them). If billing then rejects the action, the decision is compensated
|
||||
* back to PENDING so the request is not silently lost.
|
||||
*/
|
||||
async decideInvoiceAction(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ShippingLineInvoiceApproval> {
|
||||
if (!approve && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A note is required when rejecting a request.",
|
||||
);
|
||||
}
|
||||
|
||||
const decided = await this.dataSource.transaction(async (mg) => {
|
||||
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Request ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`This request was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = approve
|
||||
? ShippingLineInvoiceActionStatus.Approved
|
||||
: ShippingLineInvoiceActionStatus.Rejected;
|
||||
await mg.update(
|
||||
ShippingLineInvoiceApproval,
|
||||
{ id: approvalId },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note ?? null,
|
||||
},
|
||||
);
|
||||
return { ...approval, status, decidedBy, decisionNote: note ?? null };
|
||||
});
|
||||
|
||||
if (!approve) {
|
||||
logCtx(
|
||||
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
|
||||
{ path: "shippingLineCredit.invoiceAction.rejected" },
|
||||
);
|
||||
return decided;
|
||||
}
|
||||
|
||||
try {
|
||||
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: decided.invoiceId } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
|
||||
}
|
||||
// Full settlement of the outstanding balance; billing emits
|
||||
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
|
||||
await this.billing.recordPayment(decided.invoiceId, {
|
||||
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
method: "OFFLINE",
|
||||
reference: decided.paymentReference ?? undefined,
|
||||
metadata: {
|
||||
approvalId: decided.id,
|
||||
requestedBy: decided.requestedBy,
|
||||
approvedBy: decidedBy,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Billing emits `shipping_line_credit.invoice.cancelled`;
|
||||
// onInvoiceCancelled releases the credits back to the unbilled pool.
|
||||
await this.billing.cancelInvoice(decided.invoiceId);
|
||||
}
|
||||
} catch (err) {
|
||||
// The action was refused (state changed since the request — e.g. the
|
||||
// line paid through CBE in the meantime). Put the request back so it is
|
||||
// not recorded as approved-but-unexecuted.
|
||||
await this.approvals.update(approvalId, {
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
approvalId,
|
||||
invoiceId: decided.invoiceId,
|
||||
action: decided.action,
|
||||
decidedBy,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceAction.approved" },
|
||||
);
|
||||
|
||||
return decided;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a credit invoice is cancelled — through the approval flow or any
|
||||
* other billing path — its BILLED credits return to the unbilled pool so
|
||||
* the debt can be re-billed. The debt itself never disappears on invoice
|
||||
* cancellation; only {@link cancelCredit} writes debt off.
|
||||
*/
|
||||
@OnEvent("shipping_line_credit.invoice.cancelled")
|
||||
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
.getRepository(ShippingLineCredit)
|
||||
.update(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
status: ShippingLineCreditStatus.Billed,
|
||||
},
|
||||
{
|
||||
status: ShippingLineCreditStatus.Unbilled,
|
||||
invoiceId: null,
|
||||
billedAt: null,
|
||||
},
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: payload.invoiceId,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
creditsReleased: result.affected ?? 0,
|
||||
},
|
||||
{ path: "shippingLineCredit.invoiceCancelled.released" },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cancellation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write off an unbilled credit (booking voided, charge raised in error).
|
||||
* Only UNBILLED credits can be cancelled — once a credit is on an issued
|
||||
* invoice, the invoice is what has to be cancelled or credited, otherwise
|
||||
* the invoice total would stop matching the sum of its lines.
|
||||
*/
|
||||
async cancelCredit(
|
||||
creditId: string,
|
||||
reason: string,
|
||||
): Promise<ShippingLineCredit> {
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]);
|
||||
if (!credit) {
|
||||
throw new NotFoundException(`Credit ${creditId} not found`);
|
||||
}
|
||||
if (credit.status !== ShippingLineCreditStatus.Unbilled) {
|
||||
throw new BadRequestException(
|
||||
`Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
await mg.update(
|
||||
ShippingLineCredit,
|
||||
{ id: creditId },
|
||||
{
|
||||
status: ShippingLineCreditStatus.Cancelled,
|
||||
cancelledAt: new Date(),
|
||||
cancellationReason: reason,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...credit, status: ShippingLineCreditStatus.Cancelled };
|
||||
});
|
||||
}
|
||||
|
||||
private async requireShippingLine(shippingLineCompanyId: string) {
|
||||
const shippingLine = await this.shippingLines.findById(
|
||||
shippingLineCompanyId,
|
||||
);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ShippingLineInvoiceApproval,
|
||||
ShippingLineInvoiceActionStatus,
|
||||
} from "./entities/shipping-line-invoice-approval.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineInvoiceApproval)
|
||||
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
|
||||
) {
|
||||
super(approvals);
|
||||
}
|
||||
|
||||
findPendingByInvoice(
|
||||
invoiceId: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return this.approvals.findOne({
|
||||
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
|
||||
});
|
||||
}
|
||||
|
||||
/** Pending requests for a page of invoices — one query, no N+1. */
|
||||
findPendingByInvoiceIds(
|
||||
invoiceIds: string[],
|
||||
): Promise<ShippingLineInvoiceApproval[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return this.approvals.find({
|
||||
where: {
|
||||
invoiceId: In(invoiceIds),
|
||||
status: ShippingLineInvoiceActionStatus.Pending,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Load one request inside the caller's transaction, locked for decision. */
|
||||
findByIdForUpdate(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<ShippingLineInvoiceApproval | null> {
|
||||
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
|
||||
where: { id },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||
|
||||
@@ -81,6 +82,19 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: string | null;
|
||||
|
||||
/**
|
||||
* Dedicates this departure to one shipping line. NULL = a normal train,
|
||||
* visible to customers as today. Set = the train is HIDDEN from every
|
||||
* customer-facing read (windows, day pools, home cards) and shown only to
|
||||
* this shipping line in its portal.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
@ManyToOne(() => ShippingLineCompany)
|
||||
@JoinColumn({ name: 'shipping_line_company_id' })
|
||||
shippingLineCompany?: ShippingLineCompany | null;
|
||||
|
||||
/**
|
||||
* Reverse the wagon ORDER on this train: when true, the built wagon plan is
|
||||
* flipped at build so the physically-last wagon sits at position 1. Only the
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
} from 'typeorm';
|
||||
@@ -25,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { isRoadService } from '../bookings/road.util';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
@@ -153,6 +155,19 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
|
||||
export interface TrainOptionCargoOverrides {
|
||||
/** Container types drive the per-type space. */
|
||||
containerTypeIds?: string[];
|
||||
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||||
containerSizes?: string[];
|
||||
/** Bulk counterparts of the container inputs. */
|
||||
cargoTypeId?: string;
|
||||
cargoTypeCode?: string;
|
||||
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||||
wagons?: number;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
@@ -820,8 +835,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// stop order, so we fetch the day's open trains without endpoint filters.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
// A customer-picked train narrows the scan to that ONE schedule: export
|
||||
@@ -983,8 +999,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
@@ -1048,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async exportTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
overrides?: {
|
||||
/** Cargo the customer is entering on a form (bare contract instance —
|
||||
* nothing persisted yet): container types drive the per-type space. */
|
||||
containerTypeIds?: string[];
|
||||
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||||
containerSizes?: string[];
|
||||
/** Bulk counterparts of the container inputs. */
|
||||
cargoTypeId?: string;
|
||||
cargoTypeCode?: string;
|
||||
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||||
wagons?: number;
|
||||
},
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return this.buildTrainOptions(booking, candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same per-train wagon-availability cards, but for the trains DEDICATED
|
||||
* to a shipping line on the booking's lane + day. Same option shape as the
|
||||
* export picker so the portal reuses the same component; `isOpen`
|
||||
* additionally respects the dedicated close offset (windowClosesAt), since
|
||||
* these trains run no window cycle.
|
||||
*/
|
||||
async dedicatedTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string | null,
|
||||
shippingLineCompanyId: string,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const dedicated = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
|
||||
],
|
||||
});
|
||||
const candidates = dedicated
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
// A day narrows to that departure day; without one, every upcoming
|
||||
// departure on the lane is listed (the picker's full card list).
|
||||
(day
|
||||
? eatDay(s.scheduledDepartureDate) === day
|
||||
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
|
||||
(!booking.originYardId || s.originStationId === booking.originYardId) &&
|
||||
(!booking.destinationYardId ||
|
||||
s.destinationStationId === booking.destinationYardId),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
const options = await this.buildTrainOptions(booking, candidates);
|
||||
const now = Date.now();
|
||||
return options.map((o) => ({
|
||||
...o,
|
||||
isOpen:
|
||||
o.isOpen &&
|
||||
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
|
||||
private async withCargoOverrides(
|
||||
booking: Booking,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<Booking> {
|
||||
const sizeFts = (overrides?.containerSizes ?? [])
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
@@ -1092,25 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (overrides?.wagons && overrides.wagons > 0) {
|
||||
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
||||
}
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** One availability card per candidate schedule — the export picker's math. */
|
||||
private async buildTrainOptions(
|
||||
booking: Booking,
|
||||
candidates: TrainSchedule[],
|
||||
): Promise<ExportTrainOption[]> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
|
||||
const neededWagons = this.wagonsFor(booking, wagonDims);
|
||||
@@ -1219,8 +1290,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor.filter(
|
||||
@@ -2365,11 +2437,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
// Dedicated shipping-line trains never join the customer day pool.
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -3100,8 +3175,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const today = eatDay(new Date());
|
||||
@@ -3176,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
|
||||
* dedicated train for the booking's lane and shipment day.
|
||||
*
|
||||
* Runs at operation-accept: shipping lines pay later on the credit ledger,
|
||||
* so there is no pay window between accept and wagon placement — the
|
||||
* booking boards its train immediately. Customer bookings never come here;
|
||||
* they keep the batch pool → reserve → pay → allocate pipeline.
|
||||
*
|
||||
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
|
||||
* (without the PAID stamps the customer hold writes — nothing was paid).
|
||||
* No dedicated train on the day is not an error: the booking simply stays
|
||||
* in the ordinary day pool for the batch engine.
|
||||
*/
|
||||
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
|
||||
if (isRoadService(booking.serviceType)) return;
|
||||
|
||||
const day = eatDay(booking.scheduledDate);
|
||||
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: [
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const target = dedicated.find(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
|
||||
);
|
||||
if (!target) {
|
||||
this.logger.log(
|
||||
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
|
||||
`train on ${day} — left in the day pool for the batch engine`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Point the booking at its train BEFORE the shortage probe — the probe
|
||||
// reads the link to size the need against that schedule's wagons.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
trainScheduleId: target.id,
|
||||
} as never);
|
||||
booking.trainScheduleId = target.id;
|
||||
|
||||
// One dedicated train carries ONE booking: the accept claims the train by
|
||||
// closing its booking window on the spot. Both gates a later booking
|
||||
// passes — the day picker (isStillOpen on windowClosesAt) and the
|
||||
// completion's dedicated-day check — read these fields, so a second
|
||||
// booking can never pick this train.
|
||||
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
|
||||
bookingWindowStatus: "CLOSED",
|
||||
windowClosesAt: new Date(),
|
||||
} as never);
|
||||
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
|
||||
|
||||
const shortage =
|
||||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||||
target.id,
|
||||
booking.id,
|
||||
);
|
||||
if (shortage) {
|
||||
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
|
||||
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
schedulingStatus: "WAITING_FOR_WAGON",
|
||||
} as never);
|
||||
this.logger.warn(
|
||||
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
|
||||
`dedicated train ${target.reference ?? target.id}: needs ` +
|
||||
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
|
||||
);
|
||||
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.allocate(target.id, booking, "shipping_line");
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3535,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
reason: "paid" | "gov",
|
||||
reason: "paid" | "gov" | "shipping_line",
|
||||
): Promise<void> {
|
||||
// Stamp the computed wagon need on the link. Several callers pass a booking
|
||||
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@@ -66,10 +67,16 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
// One resolver for both channels — the company row's own email column is
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr). A
|
||||
// shipping-line booking has no company; its contact is the line's row.
|
||||
const { phone, email } = b.shippingLineCompanyId
|
||||
? await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId,
|
||||
)
|
||||
: b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -90,13 +97,42 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking's portal owner: every portal
|
||||
* user of the company, or — for a shipping-line booking — the line's own
|
||||
* account, deep-linked into the shipping-line app (/shipping-line/*).
|
||||
*/
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (b.shippingLineCompanyId) {
|
||||
void (async () => {
|
||||
const { userId } = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId!,
|
||||
);
|
||||
if (!userId) return;
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title,
|
||||
body,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
// After the spread: the bell must land the line on ITS booking page.
|
||||
link: `/shipping-line/bookings/${b.id}`,
|
||||
});
|
||||
})().catch((err) =>
|
||||
this.logger.warn(
|
||||
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
@@ -209,11 +245,19 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
secured(
|
||||
b: Booking,
|
||||
reason: 'paid' | 'gov' | 'shipping_line',
|
||||
scheduleId?: string | null,
|
||||
): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
reason === 'gov'
|
||||
? ' (government)'
|
||||
: reason === 'shipping_line'
|
||||
? ' (shipping line)'
|
||||
: ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
|
||||
@@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Dedicate this departure to one shipping line. The schedule is then hidden ' +
|
||||
'from every customer-facing read (windows, day pools, home cards) and shown ' +
|
||||
'only to that shipping line in its portal. Omit for a normal customer train.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineCompanyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: CreateScheduleWindowRuleDto,
|
||||
description:
|
||||
|
||||
@@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||
/**
|
||||
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
|
||||
* train whose next cycle would reopen at/after this hour pauses until the next
|
||||
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
|
||||
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk
|
||||
* (the default).
|
||||
*/
|
||||
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
|
||||
@Column({ name: 'window_close_hour', type: 'int', default: 8 })
|
||||
windowCloseHour!: number;
|
||||
|
||||
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity'
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
|
||||
@@ -470,7 +471,11 @@ export class TrainSchedulingService {
|
||||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||
// Dedicated shipping-line departures are never announced to the portal —
|
||||
// the broadcast reaches every customer client.
|
||||
if (fresh && !fresh.shippingLineCompanyId) {
|
||||
this.bookingWindowGateway.emitPhase(fresh);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
@@ -511,7 +516,11 @@ export class TrainSchedulingService {
|
||||
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
})
|
||||
// A dedicated shipping-line departure is never a sibling either: it runs
|
||||
// no window cycle, so it must neither anchor a customer group nor be
|
||||
// dragged through one's open/doc-review/payment instants.
|
||||
.andWhere('s.shippingLineCompanyId IS NULL');
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
@@ -1461,6 +1470,24 @@ export class TrainSchedulingService {
|
||||
|
||||
const scheduleWarnings: string[] = [];
|
||||
|
||||
// Dedicating the departure to a shipping line: the id comes from the
|
||||
// request, so verify it is a real, active line before stamping it.
|
||||
if (dto.shippingLineCompanyId) {
|
||||
const line = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { id: dto.shippingLineCompanyId } });
|
||||
if (!line) {
|
||||
throw new NotFoundException(
|
||||
`Shipping line ${dto.shippingLineCompanyId} not found`,
|
||||
);
|
||||
}
|
||||
if (line.status !== 'active') {
|
||||
throw new BadRequestException(
|
||||
`Shipping line ${line.name} is suspended — it cannot be assigned a train`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The pulling set comes either from a built train (Train Builder) or from
|
||||
// hand-picked locomotive ids (legacy path). A built train also links the
|
||||
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
|
||||
@@ -1593,8 +1620,11 @@ export class TrainSchedulingService {
|
||||
// doc-review/payment phase — so there is no cross-expiry to fix, and two
|
||||
// export trains departing the same day at different times must keep their
|
||||
// own departure-anchored windows.
|
||||
// Dedicated shipping-line departures never group either: they run no
|
||||
// window cycle at all, so sharing a customer group's timeline (or
|
||||
// anchoring one) would drag them into phases they must not have.
|
||||
const groupAnchor =
|
||||
direction === 'EXPORT'
|
||||
direction === 'EXPORT' || dto.shippingLineCompanyId
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
manager,
|
||||
@@ -1659,45 +1689,71 @@ export class TrainSchedulingService {
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||||
computedTimes.windowOpensAt = new Date();
|
||||
let windowFields: Partial<TrainSchedule>;
|
||||
if (dto.shippingLineCompanyId) {
|
||||
// Dedicated shipping-line departure: NO window cycle at all. The line
|
||||
// books whenever it wants from creation until the close offset before
|
||||
// departure. windowPhase stays NULL, so the window engine, restamp and
|
||||
// the customer window lists all skip this schedule; the close-offset
|
||||
// gate is enforced by the shipping-line completion path, which reads
|
||||
// windowClosesAt stamped here.
|
||||
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
|
||||
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||
if (closesAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException(
|
||||
'With the booking-close offset applied, this departure would already be ' +
|
||||
'closed for shipping-line booking — pick a later departure.',
|
||||
);
|
||||
}
|
||||
windowFields = {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
windowOpensAt: new Date(),
|
||||
windowClosesAt: closesAt,
|
||||
ruleImportCloseOffsetMinutes: offsetMinutes || null,
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
} else {
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||||
computedTimes.windowOpensAt = new Date();
|
||||
}
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
}
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
// A built train's own consist is the schedule's capacity: full when all
|
||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||
// the configured limit.
|
||||
@@ -1725,6 +1781,7 @@ export class TrainSchedulingService {
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
|
||||
...windowFields,
|
||||
}),
|
||||
);
|
||||
@@ -4474,7 +4531,10 @@ export class TrainSchedulingService {
|
||||
(b) =>
|
||||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||||
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
|
||||
!b.isGovernment,
|
||||
!b.isGovernment &&
|
||||
// Shipping-line bookings pay later on the credit ledger — never PAID
|
||||
// up front, schedulable from accept (FULLY_EXECUTED) like government.
|
||||
!b.shippingLineCompanyId,
|
||||
);
|
||||
if (invalidStatus.length) {
|
||||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||||
@@ -6987,6 +7047,7 @@ export class TrainSchedulingService {
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.shipping_line_company_id IS NULL
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
@@ -7043,6 +7104,7 @@ export class TrainSchedulingService {
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.shipping_line_company_id IS NULL
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
@@ -7152,6 +7214,8 @@ export class TrainSchedulingService {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
// Dedicated shipping-line trains never surface to customer booking.
|
||||
shippingLineCompanyId: IsNull(),
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||||
@@ -8586,7 +8650,12 @@ export class TrainSchedulingService {
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const eligible = linkedBookings.filter(
|
||||
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
|
||||
(b) =>
|
||||
SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') ||
|
||||
b.isGovernment ||
|
||||
// Shipping-line bookings board without paying up front — their charge
|
||||
// sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable.
|
||||
Boolean(b.shippingLineCompanyId),
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
|
||||
@@ -99,7 +99,9 @@ interface InventoryContext {
|
||||
interface ViewSource {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
companyId: string;
|
||||
/** Nullable on the entity (shipping-line invoices have no company); every
|
||||
* warehouse invoice is customer-billed, so in practice this is always set. */
|
||||
companyId: string | null;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
status: Freight.InvoiceStatus | string;
|
||||
|
||||
@@ -643,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Shipping line booking documents ─────────────────────────────────────────
|
||||
// Collected on a shipping line's booking right after it is initiated. Shipping
|
||||
// lines book without a contract, so this set — not a contract — is what
|
||||
// Operations reviews before the booking may be completed. Fields start empty
|
||||
// and are configured in the backoffice file-settings editor, like the sets
|
||||
// above. `entity: "booking"` puts it alongside the other per-booking sets.
|
||||
const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "shipping_line_booking_documents",
|
||||
label: "Shipping line booking documents",
|
||||
entity: "booking",
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Hazardous cargo documents ───────────────────────────────────────────────
|
||||
// Asked for in the contract wizard the moment the customer flags the cargo as
|
||||
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
|
||||
@@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder {
|
||||
description:
|
||||
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
|
||||
})),
|
||||
...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.",
|
||||
})),
|
||||
];
|
||||
|
||||
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||
|
||||
@@ -499,6 +499,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
// C2. Shipping lines — carriers registered by staff (no self-signup).
|
||||
export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:shipping_lines:view",
|
||||
"View shipping lines",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:shipping_lines:create",
|
||||
"Register shipping line",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:shipping_lines:update",
|
||||
"Update shipping line",
|
||||
),
|
||||
perm(
|
||||
"d1a00002-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:shipping_lines:reset-password",
|
||||
"Resend shipping line activation link",
|
||||
),
|
||||
];
|
||||
|
||||
// D. Finance — payments + invoices
|
||||
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -552,6 +576,47 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:confirm_offline",
|
||||
"Confirm offline (bank transfer) invoice payment",
|
||||
),
|
||||
// Shipping lines consume services on credit and are invoiced after the fact,
|
||||
// so what they owe is its own Finance surface, separate from invoices:view —
|
||||
// an unbilled credit is not an invoice yet.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:shipping_line_credits:view",
|
||||
"View shipping-line credits and outstanding balance",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:shipping_line_credits:invoice",
|
||||
"Generate an invoice from shipping-line credits",
|
||||
),
|
||||
// Erases a debt outright, which is why it is not folded into :invoice.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:shipping_line_credits:cancel",
|
||||
"Cancel (write off) an unbilled shipping-line credit",
|
||||
),
|
||||
// Two-step manual actions on credit invoices: request grants per action,
|
||||
// decision grants that apply to any pending request.
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
"Request marking a shipping-line credit invoice paid (offline payment)",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000005",
|
||||
"edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
"Approve any pending shipping-line credit invoice request",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
"Request cancelling a shipping-line credit invoice",
|
||||
),
|
||||
perm(
|
||||
"d2c00001-0001-4000-8000-000000000007",
|
||||
"edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
"Reject any pending shipping-line credit invoice request",
|
||||
),
|
||||
];
|
||||
|
||||
// E. First / last mile operations
|
||||
@@ -1567,6 +1632,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...REPORT_PERMISSIONS,
|
||||
...CUSTOMER_PERMISSIONS,
|
||||
...SHIPPING_LINE_PERMISSIONS,
|
||||
...FINANCE_PERMISSIONS,
|
||||
...MILE_PERMISSIONS,
|
||||
...FLEET_RAIL_PERMISSIONS,
|
||||
@@ -1773,6 +1839,31 @@ export const FREIGHT_PERMS = {
|
||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:customers:get_notification",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
update: "edr_freight_app:shipping_lines:update",
|
||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||
},
|
||||
shippingLineCredits: {
|
||||
view: "edr_freight_app:shipping_line_credits:view",
|
||||
/** Turn a batch of unbilled credits into an invoice. */
|
||||
invoice: "edr_freight_app:shipping_line_credits:invoice",
|
||||
/** Write off an unbilled credit — separate grant: it erases a debt. */
|
||||
cancel: "edr_freight_app:shipping_line_credits:cancel",
|
||||
// Two-step manual actions on credit invoices, gated purely by permission:
|
||||
// finance-level REQUEST grants (per action) and decision grants that apply
|
||||
// to ANY pending request — including the holder's own.
|
||||
/** Request recording an offline payment against a credit invoice. */
|
||||
invoiceMarkPaid:
|
||||
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
/** Request voiding a credit invoice (credits return to unbilled). */
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
/** Approve any pending invoice request (mark-paid or cancel). */
|
||||
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
/** Reject any pending invoice request. */
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
@@ -2320,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
// Shipping-line credit ledger is a Finance surface: bill batches into
|
||||
// invoices and RAISE manual invoice actions. Approval of those actions is
|
||||
// deliberately absent — it sits with the chief (maker–checker).
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoice,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
],
|
||||
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
|
||||
// the general booking-request list (no bookings:view) — instead a dedicated
|
||||
@@ -2422,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.payments.view,
|
||||
// Decision side of the credit-invoice two-step: finance raises
|
||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||
FREIGHT_PERMS.shippingLineCredits.view,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
]),
|
||||
// Director additionally manages train scheduling + rail fleet (same block the
|
||||
// operation officer/chief hold), on top of the approval-chain role preset,
|
||||
|
||||
@@ -36,6 +36,8 @@ 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";
|
||||
@@ -317,6 +319,24 @@ const App = () => {
|
||||
OR'd across both keys so a user with just one still gets in; each
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
<Route
|
||||
path="shipping-lines"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||
<ShippingLineCompaniesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipping-line-credits"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.shippingLineCredits.view}
|
||||
>
|
||||
<ShippingLineCreditsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { 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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
|
||||
current[name] ? { ...current, [name]: "" } : current,
|
||||
);
|
||||
setValues((current) => {
|
||||
// Mantine fires onChange even when the same option is re-picked, and the
|
||||
// cascades below clear dependent answers (yards, unit, scope). Re-picking
|
||||
// an unchanged value must be a no-op, or an untouched direction silently
|
||||
// wipes the yard pair and the submit fails with "origin/destination
|
||||
// missing" data the admin did fill in.
|
||||
if (current[name] === value) return current;
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
@@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
next.shippingLineCompanyId = "";
|
||||
next.shippingLineRateKind = "";
|
||||
next.shippingLineCargoKind = "";
|
||||
next.appliesTo = "";
|
||||
next.trigger = "";
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
|
||||
// the legal units for a shipping-line rate, exactly as appliesTo and
|
||||
// cargoKind do on the customer form.
|
||||
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
if (name === "shippingLineRateKind") {
|
||||
next.shippingLineCargoKind = "";
|
||||
next.trigger = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{field.description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{field.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
@@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
const computedSelect = field.computeValue
|
||||
? String(field.computeValue(values) ?? "")
|
||||
: undefined;
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -501,9 +552,18 @@ const RuleEngineFormDialog = ({
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computedSelect !== undefined
|
||||
}
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Ban, Check, HandCoins, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatMoney } from "@/components/customers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreditInvoiceActionType,
|
||||
CreditInvoicePendingAction,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
/** The slice of an invoice row the actions need — both list pages have it. */
|
||||
export interface CreditInvoiceActionTarget {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
}
|
||||
|
||||
/** Statuses an offline payment can still be recorded against. */
|
||||
const MARK_PAID_STATUSES = new Set([
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PAYMENT_PROCESSING",
|
||||
"PARTIALLY_PAID",
|
||||
"OVERDUE",
|
||||
]);
|
||||
|
||||
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
|
||||
MARK_PAID: "Mark paid",
|
||||
CANCEL: "Cancel invoice",
|
||||
};
|
||||
|
||||
export interface CreditInvoiceActionsProps {
|
||||
invoice: CreditInvoiceActionTarget;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
|
||||
* invoice list. Gated purely by permission: the request grants raise
|
||||
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
|
||||
* the holder's own included. Renders only the buttons the signed-in user's
|
||||
* grants allow; the API enforces the same gates server-side.
|
||||
*/
|
||||
export default function CreditInvoiceActions({
|
||||
invoice,
|
||||
pendingAction,
|
||||
}: CreditInvoiceActionsProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const canRequestPaid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
);
|
||||
const canRequestCancel = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
);
|
||||
const canApprove = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
);
|
||||
const canReject = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
);
|
||||
|
||||
const [requestAction, setRequestActionModal] =
|
||||
useState<CreditInvoiceActionType | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [paymentReference, setPaymentReference] = useState("");
|
||||
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
|
||||
const [decisionNote, setDecisionNote] = useState("");
|
||||
|
||||
const closeRequest = () => {
|
||||
setRequestActionModal(null);
|
||||
setReason("");
|
||||
setPaymentReference("");
|
||||
};
|
||||
const closeDecide = () => {
|
||||
setDecideApprove(null);
|
||||
setDecisionNote("");
|
||||
};
|
||||
|
||||
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
|
||||
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeRequest();
|
||||
toast({
|
||||
title: "Request submitted",
|
||||
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not submit request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
|
||||
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeDecide();
|
||||
toast({
|
||||
title: variables.approve ? "Request approved" : "Request rejected",
|
||||
description: variables.approve
|
||||
? pendingAction?.action === "MARK_PAID"
|
||||
? "The offline payment was recorded; the invoice and its credits are now paid."
|
||||
: "The invoice was cancelled; its credits returned to the unbilled pool."
|
||||
: "The request was rejected and nothing was changed.",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not decide request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let body = null;
|
||||
if (pendingAction) {
|
||||
body = (
|
||||
<Stack gap={6} py={4}>
|
||||
<Badge variant="light" color="orange" title={pendingAction.reason}>
|
||||
{ACTION_LABEL[pendingAction.action]} — awaiting approval
|
||||
</Badge>
|
||||
{canApprove || canReject ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canApprove ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={12} />}
|
||||
onClick={() => setDecideApprove(true)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<X size={12} />}
|
||||
onClick={() => setDecideApprove(false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
const showMarkPaid =
|
||||
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
|
||||
const showCancel =
|
||||
canRequestCancel &&
|
||||
invoice.status !== "CANCELLED" &&
|
||||
invoice.status !== "PAID" &&
|
||||
invoice.status !== "REFUNDED" &&
|
||||
Number(invoice.paidAmount) === 0;
|
||||
body =
|
||||
!showMarkPaid && !showCancel ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{showMarkPaid ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<HandCoins size={12} />}
|
||||
onClick={() => setRequestActionModal("MARK_PAID")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
) : null}
|
||||
{showCancel ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Ban size={12} />}
|
||||
onClick={() => setRequestActionModal("CANCEL")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
|
||||
{/* Maker: raise the request. */}
|
||||
<Modal
|
||||
opened={requestAction !== null}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
requestAction
|
||||
? `${ACTION_LABEL[requestAction]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{requestAction === "MARK_PAID"
|
||||
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
|
||||
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
|
||||
</Text>
|
||||
{requestAction === "MARK_PAID" ? (
|
||||
<TextInput
|
||||
label="Payment reference"
|
||||
description="Bank slip / transfer number, if any."
|
||||
value={paymentReference}
|
||||
onChange={(e) => setPaymentReference(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
minRows={2}
|
||||
placeholder={
|
||||
requestAction === "MARK_PAID"
|
||||
? "Paid by bank transfer, slip #…"
|
||||
: "Raised in error / rebilling with corrections…"
|
||||
}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isRequesting}
|
||||
disabled={reason.trim().length < 3}
|
||||
onClick={() =>
|
||||
requestAction &&
|
||||
submitRequest({
|
||||
invoiceId: invoice.id,
|
||||
action: requestAction,
|
||||
reason: reason.trim(),
|
||||
paymentReference: paymentReference.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit for approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Checker: decide the request. */}
|
||||
<Modal
|
||||
opened={decideApprove !== null}
|
||||
onClose={closeDecide}
|
||||
title={
|
||||
pendingAction
|
||||
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{pendingAction ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Requested reason:{" "}
|
||||
</Text>
|
||||
{pendingAction.reason}
|
||||
</Text>
|
||||
{pendingAction.paymentReference ? (
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Payment reference:{" "}
|
||||
</Text>
|
||||
{pendingAction.paymentReference}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
{decideApprove && pendingAction ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingAction.action === "MARK_PAID"
|
||||
? `Approving records ${formatMoney(
|
||||
Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)} as paid offline and settles the invoice's credits.`
|
||||
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
|
||||
</Text>
|
||||
) : null}
|
||||
<Textarea
|
||||
label={decideApprove ? "Note (optional)" : "Rejection note"}
|
||||
withAsterisk={!decideApprove}
|
||||
minRows={2}
|
||||
value={decisionNote}
|
||||
onChange={(e) => setDecisionNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeDecide}
|
||||
disabled={isDeciding}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color={decideApprove ? "edr-green" : "red"}
|
||||
loading={isDeciding}
|
||||
disabled={!decideApprove && !decisionNote.trim()}
|
||||
onClick={() =>
|
||||
pendingAction &&
|
||||
decideApprove !== null &&
|
||||
submitDecision({
|
||||
approvalId: pendingAction.id,
|
||||
approve: decideApprove,
|
||||
note: decisionNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{decideApprove ? "Approve & execute" : "Reject request"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ResetChannel,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* The carrier integration is domestic-only: anything else is queued and
|
||||
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||
*/
|
||||
function isDomesticPhone(rawPhone: string): boolean {
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||
? `+251${digits.replace(/^0+/, "")}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized);
|
||||
}
|
||||
|
||||
export interface ResendActivationActionProps {
|
||||
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a shipping line's activation link.
|
||||
*
|
||||
* The same single-use link registration sends: the carrier opens it and picks
|
||||
* their own password, so no credential is ever shown to or handled by staff.
|
||||
* Needed whenever the original send failed, expired (24h), or never arrived.
|
||||
*/
|
||||
export default function ResendActivationAction({
|
||||
shippingLine,
|
||||
}: ResendActivationActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send activation link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
const phoneUsable =
|
||||
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||
const channelMissing = channel === "phone" && !phoneUsable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Resend activation link"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to {shippingLine.name}. They choose
|
||||
their own password — you will not see it. The link expires in 24
|
||||
hours, and sending a new one invalidates nothing they haven't
|
||||
already used.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label="Send the link via"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={shippingLine.email}
|
||||
/>
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!shippingLine.phoneNumber
|
||||
? "No phone number on this account"
|
||||
: !phoneUsable
|
||||
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||
: shippingLine.phoneNumber
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{channelMissing ? (
|
||||
<Alert color="yellow" variant="light" p="sm">
|
||||
<Text size="sm">
|
||||
This account has no number the SMS gateway can reach. Send the
|
||||
link by email instead.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||
>
|
||||
Send activation link
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -28,6 +28,48 @@ export const QUERY_KEYS = {
|
||||
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
ROOT: ["shipping-line-companies"] as const,
|
||||
list: (page: number, limit: number) =>
|
||||
["shipping-line-companies", "list", page, limit] as const,
|
||||
byId: (id: string) =>
|
||||
["shipping-line-companies", "detail", id] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
ROOT: ["shipping-line-credits"] as const,
|
||||
invoices: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"invoices",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
summary: (shippingLineId?: string) =>
|
||||
["shipping-line-credits", "summary", shippingLineId ?? "all"] as const,
|
||||
list: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"list",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
},
|
||||
|
||||
CUSTOMERS: {
|
||||
ROOT: ["customers"] as const,
|
||||
stats: ["customers", "stats"] as const,
|
||||
|
||||
@@ -77,6 +77,33 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
/**
|
||||
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
|
||||
* rule-engine's pricing lookup list (a code/label bookings reference).
|
||||
*/
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
BASE: "/shipping-line-companies",
|
||||
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
|
||||
RESEND_ACTIVATION: (id: string) =>
|
||||
`/shipping-line-companies/${id}/resend-activation`,
|
||||
},
|
||||
|
||||
/** Finance's view of what shipping lines owe (use now, pay later). */
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
BASE: "/shipping-line-credits",
|
||||
SUMMARY: "/shipping-line-credits/summary",
|
||||
INVOICE: "/shipping-line-credits/invoice",
|
||||
INVOICES: "/shipping-line-credits/invoices",
|
||||
MARK_PAID_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/mark-paid-request`,
|
||||
CANCEL_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/cancel-request`,
|
||||
APPROVE_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/approve`,
|
||||
REJECT_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/reject`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
STATS: "/companies/stats",
|
||||
|
||||
@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { api } from "@/services/api";
|
||||
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
|
||||
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping lines a rate can be scoped to. Only ACTIVE lines are offered — the
|
||||
* API refuses a rate filed against a suspended one, so listing them would only
|
||||
* produce an error on submit. Sorted by name so the picker is scannable.
|
||||
*/
|
||||
export const useShippingLineCompanyOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("shipping-line-companies", {}),
|
||||
// One page well past the number of carriers on the corridor; the picker
|
||||
// needs the whole list, not a page of it.
|
||||
queryFn: () => shippingLineCompaniesService.list(1, 200),
|
||||
enabled,
|
||||
select: (page: PaginatedShippingLineCompanies) =>
|
||||
page.items
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({
|
||||
label: line.scacCode ? `${line.name} (${line.scacCode})` : line.name,
|
||||
value: line.id,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
|
||||
@@ -123,6 +123,21 @@ export const FREIGHT_PERMS = {
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
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",
|
||||
invoice: "edr_freight_app:shipping_line_credits:invoice",
|
||||
cancel: "edr_freight_app:shipping_line_credits:cancel",
|
||||
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
|
||||
@@ -138,9 +138,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
// Querying a document is only possible while the booking is actually in
|
||||
// review — the server enforces exactly that (reviewDocument asserts
|
||||
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
|
||||
// only ever produce a 400.
|
||||
//
|
||||
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
|
||||
// so a non-customs booking (self-clearance, and every shipping-line booking)
|
||||
// never sets it and kept offering Query after Operations had finalized.
|
||||
const queriesLocked =
|
||||
Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)
|
||||
?.preClearanceFinalized,
|
||||
) ||
|
||||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
// Shipping-line credit invoices carry maker–checker actions (mark paid /
|
||||
// cancel). One batched lookup fetches the visible rows' pending requests.
|
||||
const creditInvoiceIds = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((inv) => inv.source === "shipping_line_credit")
|
||||
.map((inv) => inv.id),
|
||||
[rows],
|
||||
);
|
||||
const { data: pendingActions } = useQuery(
|
||||
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
|
||||
input: { invoiceIds: creditInvoiceIds },
|
||||
enabled: creditInvoiceIds.length > 0,
|
||||
}),
|
||||
);
|
||||
const pendingByInvoice = useMemo(
|
||||
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
|
||||
[pendingActions],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
// Only shipping-line credit invoices have manual maker–checker
|
||||
// actions; every other source settles through its own flow.
|
||||
if (inv.source !== "shipping_line_credit") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CreditInvoiceActions
|
||||
invoice={inv}
|
||||
pendingAction={pendingByInvoice.get(inv.id) ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[pendingByInvoice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useShippingLineCompanyOptions,
|
||||
useWagonTypeOptions,
|
||||
useYardOptions,
|
||||
type YardOption,
|
||||
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
|
||||
values: Record<string, unknown>,
|
||||
end: "origin" | "destination",
|
||||
): { label: string; value: string }[] => {
|
||||
// A shipping-line rate names its shape in its own fields and is always
|
||||
// import; map it onto the appliesTo/direction pair the rest of this function
|
||||
// reads so the country narrowing is shared rather than duplicated.
|
||||
if (values.isShippingLineRate === true) {
|
||||
if (!values.shippingLineCompanyId) return [];
|
||||
const isBase = values.shippingLineRateKind === "BASE";
|
||||
values = {
|
||||
...values,
|
||||
appliesTo: isBase
|
||||
? String(values.shippingLineCargoKind ?? "")
|
||||
: "OTHER",
|
||||
tradeDirection: "IMPORT",
|
||||
};
|
||||
}
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(false, usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const usesShippingLineField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
||||
);
|
||||
const {
|
||||
data: shippingLineOptions,
|
||||
isLoading: shippingLineOptionsLoading,
|
||||
} = useShippingLineCompanyOptions(usesShippingLineField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
const usesYardField = Boolean(
|
||||
@@ -390,6 +412,13 @@ const RuleEngineResourcePage = () => {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (field.name === "shippingLineCompanyId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: shippingLineOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
let payload = values;
|
||||
if (config.slug === "rates") {
|
||||
if (config.slug === "rates" && values.isShippingLineRate === true) {
|
||||
// A shipping-line rate asks its shape as "base freight vs surcharge" +
|
||||
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
|
||||
// customer rate, so translate here and drop the form-only fields. Always
|
||||
// import (the only direction a line ships) and always USD.
|
||||
const {
|
||||
isShippingLineRate: _toggle,
|
||||
shippingLineRateKind,
|
||||
shippingLineCargoKind,
|
||||
...rest
|
||||
} = values;
|
||||
void _toggle;
|
||||
const isBase = shippingLineRateKind === "BASE";
|
||||
payload = {
|
||||
...rest,
|
||||
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
|
||||
trigger: isBase ? "ALWAYS" : values.trigger,
|
||||
tradeDirection: "IMPORT",
|
||||
currency: "USD",
|
||||
};
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (config.slug === "rates") {
|
||||
// Base-freight categories have no surcharge trigger field — the engine
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
@@ -621,7 +682,11 @@ const RuleEngineResourcePage = () => {
|
||||
// ton·km, container = per km + distance band) and the currency stays as
|
||||
// chosen (birr or dollar). Everything else remains USD-only.
|
||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||
const { lastMileMode, ...rest } = values;
|
||||
// The shipping-line toggle is form-only — the API's whitelist rejects the
|
||||
// whole payload if it leaks through ("property isShippingLineRate should
|
||||
// not exist").
|
||||
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
|
||||
void _toggle;
|
||||
payload = {
|
||||
...rest,
|
||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||
@@ -934,6 +999,7 @@ const RuleEngineResourcePage = () => {
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesShippingLineField && shippingLineOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
|
||||
@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
filters: {
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* "true" = only shipping-line rates, "false" = only standard customer
|
||||
* rates. Sent as a string because tab filters go on the query string
|
||||
* verbatim.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A shipping-line rate: priced for one carrier's own bookings instead of for
|
||||
* every customer. The toggle drives the whole form — until a line is picked
|
||||
* there is nothing to configure, and the shape questions (base freight vs
|
||||
* surcharge, container vs bulk) are asked only after it is.
|
||||
*/
|
||||
const isShippingLineRate = (values: Record<string, unknown>) =>
|
||||
values.isShippingLineRate === true;
|
||||
|
||||
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
|
||||
const hasShippingLine = (values: Record<string, unknown>) =>
|
||||
isShippingLineRate(values) && Boolean(values.shippingLineCompanyId);
|
||||
|
||||
/**
|
||||
* What a shipping-line rate prices. Deliberately narrower than the customer
|
||||
* form's `appliesTo`: a line buys base rail freight (its own containers or
|
||||
* bulk) or a surcharge, and nothing else — intercity and first/last mile are
|
||||
* customer products.
|
||||
*/
|
||||
const SHIPPING_LINE_RATE_KINDS = [
|
||||
{ label: "Base freight", value: "BASE" },
|
||||
{ label: "Surcharge", value: "SURCHARGE" },
|
||||
];
|
||||
|
||||
/** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */
|
||||
const SHIPPING_LINE_CARGO_KINDS = [
|
||||
{ label: "Container", value: "CONTAINER" },
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
@@ -214,7 +253,15 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
// A shipping line's base freight is priced per leg exactly like a customer's;
|
||||
// its surcharges are route-scoped on the same triggers.
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
|
||||
values: Record<string, unknown>,
|
||||
cargoUnitOfMeasure = "",
|
||||
) => {
|
||||
// A shipping-line rate answers the same two questions under different names —
|
||||
// map them onto the shape the unit table is keyed by. Base freight for a line
|
||||
// is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger.
|
||||
if (isShippingLineRate(values)) {
|
||||
const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values;
|
||||
if (kind === "BASE") {
|
||||
if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return [];
|
||||
return allowedRateUnits(
|
||||
String(cargoKind),
|
||||
"ALWAYS",
|
||||
"",
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
if (kind === "SURCHARGE" && values.trigger) {
|
||||
return allowedRateUnits(
|
||||
"OTHER",
|
||||
String(values.trigger),
|
||||
String(values.cargoKind ?? ""),
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
// "All" and every shape tab show customer rates only — a shipping line's
|
||||
// negotiated price is its own list, not an extra row in the standard one.
|
||||
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "shipping-line",
|
||||
label: "Shipping line",
|
||||
filters: { isShippingLineRate: "true" },
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
isShippingLineRate: "false",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
// Blank on a standard customer rate; the owning carrier on a line rate.
|
||||
{
|
||||
id: "shippingLineCompany",
|
||||
header: "Shipping line",
|
||||
accessorKey: "shippingLineCompany",
|
||||
format: "entityLabel",
|
||||
},
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
// Base freight is priced per leg, so the route is what tells two otherwise
|
||||
@@ -879,6 +966,59 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
// ── Shipping line rate ────────────────────────────────────────────────
|
||||
// Flipping this on replaces the whole customer form: the only question
|
||||
// is which line, and the shape questions follow once it is answered.
|
||||
{
|
||||
name: "isShippingLineRate",
|
||||
label: "Shipping line rate",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.",
|
||||
// The owner is part of a rate's identity, so switching an existing rate
|
||||
// between customer and line pricing would silently re-target every
|
||||
// booking that prices off it. Create a new rate instead.
|
||||
disabledOnEdit: true,
|
||||
getInitialValue: (record) => Boolean(record.shippingLineCompanyId),
|
||||
},
|
||||
{
|
||||
name: "shippingLineCompanyId",
|
||||
label: "Shipping line",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which shipping line this rate is for",
|
||||
description:
|
||||
"Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.",
|
||||
disabledOnEdit: true,
|
||||
showIf: isShippingLineRate,
|
||||
},
|
||||
// What the line is buying. Asked only after a line is picked, so the form
|
||||
// stays a single question until then.
|
||||
{
|
||||
name: "shippingLineRateKind",
|
||||
label: "Rate type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_RATE_KINDS,
|
||||
placeholder: "Base freight or a surcharge?",
|
||||
showIf: hasShippingLine,
|
||||
// Not stored: base freight carries trigger ALWAYS, a surcharge anything else.
|
||||
getInitialValue: (record) =>
|
||||
!record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE",
|
||||
},
|
||||
// Container vs bulk — the line form asks this directly instead of folding
|
||||
// it into `appliesTo` the way the customer form does.
|
||||
{
|
||||
name: "shippingLineCargoKind",
|
||||
label: "Cargo kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_CARGO_KINDS,
|
||||
placeholder: "Is this rate for containers or bulk?",
|
||||
showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE",
|
||||
getInitialValue: (record) =>
|
||||
record.appliesTo === "BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "appliesTo",
|
||||
label: "Applies to",
|
||||
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_APPLIES_TO,
|
||||
description:
|
||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||
// Derived from the two questions above on a shipping-line rate.
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||
{
|
||||
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// The same trigger list for a shipping-line surcharge — a line incurs the
|
||||
// same charges a customer does (hazard, reefer, demurrage …), just at its
|
||||
// own negotiated price.
|
||||
{
|
||||
name: "trigger",
|
||||
label: "Surcharge trigger",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
)),
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
// as a locked field rather than hidden so the lane the yard pickers are
|
||||
// filtered by is visible.
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ label: "Import", value: "IMPORT" }],
|
||||
description: "Shipping line rates are import-only.",
|
||||
disabled: true,
|
||||
// No defaultValue: field names repeat across form variants and the
|
||||
// seeded initial value is shared, so defaulting here would pre-select
|
||||
// Import on the customer form's own direction field too. computeValue
|
||||
// pins IMPORT on submit and locks the input regardless.
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")),
|
||||
},
|
||||
// Bulk commodity for a shipping-line base-freight rate. Its unit of
|
||||
// measure decides the rate unit offered below — a counted commodity
|
||||
// (PER_ITEM) prices per item where a weighed one prices per ton.
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which bulk commodity this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "BULK",
|
||||
},
|
||||
// ── The leg this rate prices — base freight only ──────────────────────
|
||||
// Options are narrowed to the countries the direction allows (import
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** SCAC is 2-4 letters; the API enforces the same rule. */
|
||||
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
scacCode: string;
|
||||
imoNumber: string;
|
||||
bicCode: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormValues = {
|
||||
name: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
scacCode: "",
|
||||
imoNumber: "",
|
||||
bicCode: "",
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping line companies — carriers with their own portal login.
|
||||
*
|
||||
* Registration is staff-only: there is no self-signup. Staff never set a
|
||||
* password; the system emails (and texts, when the number is domestic) a
|
||||
* single-use activation link that the carrier uses to choose their own.
|
||||
*/
|
||||
export default function ShippingLineCompaniesPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
limit: pagination.pageSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const setField = (field: keyof FormValues) => (value: string) =>
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Mirrors the API's own validation, so the obvious mistakes are caught before
|
||||
// a round trip. The server still enforces all of it.
|
||||
const errors = {
|
||||
name: values.name.trim() ? null : "Company name is required",
|
||||
// Required, unlike a customer's: the activation link is sent here, so an
|
||||
// account without one could never be signed in to.
|
||||
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
|
||||
? null
|
||||
: "A valid email is required",
|
||||
scacCode:
|
||||
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
|
||||
? null
|
||||
: "SCAC must be 2-4 letters",
|
||||
};
|
||||
const isValid = !errors.name && !errors.email && !errors.scacCode;
|
||||
|
||||
const closeRegister = () => {
|
||||
setRegisterOpen(false);
|
||||
setValues(EMPTY_FORM);
|
||||
setTouched(false);
|
||||
};
|
||||
|
||||
const { mutate: register, isPending: isRegistering } = useMutation(
|
||||
api.shippingLineCompanies.register.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
closeRegister();
|
||||
toast({
|
||||
title: "Shipping line registered",
|
||||
description: result.activationSentTo
|
||||
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||
: // The account exists and is valid — only delivery failed, and the
|
||||
// link can be resent, so this is a warning rather than an error.
|
||||
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
|
||||
variant: result.activationSentTo ? undefined : "destructive",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not register shipping line",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Shipping line",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
<Ship size={18} strokeWidth={1.9} />
|
||||
</Box>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
{sl.name}
|
||||
</Text>
|
||||
{sl.scacCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
SCAC {sl.scacCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Mail size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" truncate>
|
||||
{sl.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{sl.phoneNumber ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Phone size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{sl.phoneNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "identifiers",
|
||||
header: "Identifiers",
|
||||
cell: ({ row }) => {
|
||||
const { imoNumber, bicCode } = row.original;
|
||||
if (!imoNumber && !bicCode) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
|
||||
{bicCode ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
BIC {bicCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={row.original.status === "active" ? "green" : "red"}
|
||||
>
|
||||
{row.original.status === "active" ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Registered",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ResendActivationAction shippingLine={row.original} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Lines"
|
||||
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
>
|
||||
Register shipping line
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No shipping lines registered yet."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load shipping lines.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={registerOpen}
|
||||
onClose={closeRegister}
|
||||
title="Register shipping line"
|
||||
centered
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
if (!isValid) return;
|
||||
register({
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
phoneNumber: values.phoneNumber.trim() || undefined,
|
||||
scacCode: values.scacCode.trim() || undefined,
|
||||
imoNumber: values.imoNumber.trim() || undefined,
|
||||
bicCode: values.bicCode.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
No password is set here. The shipping line receives a single-use
|
||||
activation link and chooses their own.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
placeholder="Ethiopian Shipping Lines"
|
||||
withAsterisk
|
||||
value={values.name}
|
||||
onChange={(e) => setField("name")(e.currentTarget.value)}
|
||||
error={touched ? errors.name : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="ops@example.com"
|
||||
description="The activation link is sent here."
|
||||
withAsterisk
|
||||
value={values.email}
|
||||
onChange={(e) => setField("email")(e.currentTarget.value)}
|
||||
error={touched ? errors.email : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251911223344"
|
||||
description="Ethiopian numbers also receive the link by SMS."
|
||||
value={values.phoneNumber}
|
||||
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="SCAC"
|
||||
placeholder="ESLK"
|
||||
value={values.scacCode}
|
||||
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
|
||||
error={touched ? errors.scacCode : null}
|
||||
/>
|
||||
<TextInput
|
||||
label="IMO number"
|
||||
placeholder="IMO9074729"
|
||||
value={values.imoNumber}
|
||||
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="BIC code"
|
||||
placeholder="ESLU"
|
||||
value={values.bicCode}
|
||||
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isRegistering}>
|
||||
Register & send link
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreditInvoice } from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
ISSUED: { label: "Issued", color: "orange" },
|
||||
PENDING: { label: "Pending", color: "orange" },
|
||||
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
|
||||
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
OVERDUE: { label: "Overdue", color: "red" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
REFUNDED: { label: "Refunded", color: "blue" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
|
||||
([value, meta]) => ({ value, label: meta.label }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Invoices minted from credit batches. The actions column is the shared
|
||||
* maker–checker component (also embedded on the Finance hub's invoice list):
|
||||
* finance requests mark-paid / cancel, a chief approves or rejects.
|
||||
*/
|
||||
export default function ShippingLineCreditInvoicesPanel() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
|
||||
api.shippingLineCredits.listInvoices.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const columns: ColumnDef<CreditInvoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm font-semibold text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{inv.shippingLineName ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
header: () => <span className={bookingTable.headerCell}>Issued</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "due",
|
||||
header: () => <span className={bookingTable.headerCell}>Due</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
header: () => <span className={bookingTable.headerCell}>Balance</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.balanceAmount ?? row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = INVOICE_STATUS_META[row.original.status] ?? {
|
||||
label: row.original.status,
|
||||
color: "gray",
|
||||
};
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<CreditInvoiceActions
|
||||
invoice={row.original}
|
||||
pendingAction={row.original.pendingAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={() => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credit invoices yet — generate one from the Credits tab."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Stack, Tabs } from "@mantine/core";
|
||||
import { HandCoins, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
|
||||
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
|
||||
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
|
||||
* FinanceHubPage convention): the credit ledger (select unbilled credits →
|
||||
* generate an invoice) and the invoices minted from it (maker–checker
|
||||
* mark-paid / cancel actions).
|
||||
*/
|
||||
export default function ShippingLineCreditsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab =
|
||||
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Line Credits"
|
||||
subtitle={
|
||||
activeTab === "invoices"
|
||||
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
|
||||
: "What each line owes — outstanding totals and the full credit ledger."
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
|
||||
Credits
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="credits" pt="lg">
|
||||
<ShippingLineCreditsPanel />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="invoices" pt="lg">
|
||||
<ShippingLineCreditInvoicesPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
FilterX,
|
||||
HandCoins,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_META: Record<
|
||||
ShippingLineCreditStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
UNBILLED: { label: "Unbilled", color: "orange" },
|
||||
BILLED: { label: "Billed", color: "blue" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Every shipping line's credits in one list — finance's landing view, styled
|
||||
* to match the booking-requests page. Summary cells total the current filter
|
||||
* scope (all lines by default); the selects narrow both cells and ledger.
|
||||
*/
|
||||
export default function ShippingLineCreditsPanel() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const canInvoice = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoice,
|
||||
);
|
||||
|
||||
// Selection for batch invoicing, kept as id → credit so it survives page
|
||||
// changes and can total itself. One invoice has one payer, so everything
|
||||
// selected must belong to the same shipping line — enforced here so the
|
||||
// API's rejection is never the first time staff hears about it.
|
||||
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
|
||||
new Map(),
|
||||
);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [dueInDays, setDueInDays] = useState<number | "">("");
|
||||
|
||||
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
|
||||
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
|
||||
const selectedTotal = selectedCredits.reduce(
|
||||
(sum, c) => sum + Number(c.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
const toggleSelected = (credit: ShippingLineCredit) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(credit.id)) next.delete(credit.id);
|
||||
else next.set(credit.id, credit);
|
||||
return next;
|
||||
});
|
||||
|
||||
const clearSelection = () => setSelected(new Map());
|
||||
|
||||
// ponytail: first 100 lines in the picker; server-side search when a real
|
||||
// deployment outgrows that.
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({
|
||||
value: sl.id,
|
||||
label: sl.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
|
||||
})),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.summary.queryOptions({
|
||||
input: { shippingLineId: shippingLineId ?? undefined },
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
data: ledger,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = ledger?.items ?? [];
|
||||
const total = ledger?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const clearFilters = () => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
};
|
||||
|
||||
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
|
||||
api.shippingLineCredits.generateInvoice.mutationOptions({
|
||||
onSuccess: (invoice) => {
|
||||
setInvoiceOpen(false);
|
||||
clearSelection();
|
||||
setDueInDays("");
|
||||
toast({
|
||||
title: `Invoice ${invoice.invoiceNumber} generated`,
|
||||
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not generate invoice",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
// A concurrent edit (someone else billed a selected credit) is the
|
||||
// usual cause — resync so stale rows drop out of the list.
|
||||
handleRefresh();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
|
||||
() => [
|
||||
...(canInvoice
|
||||
? [
|
||||
{
|
||||
id: "select",
|
||||
size: 40,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
|
||||
const credit = row.original;
|
||||
const selectable =
|
||||
credit.status === "UNBILLED" &&
|
||||
(selectedLineId === null ||
|
||||
credit.shippingLineCompanyId === selectedLineId);
|
||||
return (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected.has(credit.id)}
|
||||
disabled={!selectable}
|
||||
title={
|
||||
credit.status !== "UNBILLED"
|
||||
? "Only unbilled credits can be invoiced"
|
||||
: !selectable
|
||||
? "One invoice has one payer — selection already holds another line's credits"
|
||||
: undefined
|
||||
}
|
||||
onChange={() => toggleSelected(credit)}
|
||||
aria-label="Select credit for invoicing"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "shippingLine",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Shipping line</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const credit = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{credit.shippingLineCompany?.name ?? "—"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{credit.booking?.reference ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Description</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(Number(row.original.amount), row.original.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original.invoice;
|
||||
return inv ? (
|
||||
<span className="truncate font-mono text-xs text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
// Selection state drives the checkbox column's checked/disabled rendering.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[canInvoice, selected, selectedLineId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Total outstanding",
|
||||
value: summary
|
||||
? formatMoney(summary.totalOutstanding, summary.currency)
|
||||
: "—",
|
||||
hint: "unbilled + billed",
|
||||
icon: HandCoins,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Unbilled",
|
||||
value: summary
|
||||
? formatMoney(summary.unbilledAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.unbilledCount} credits` : undefined,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Billed",
|
||||
value: summary
|
||||
? formatMoney(summary.billedAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.billedCount} on invoices` : undefined,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as ShippingLineCreditStatus | null) ?? null);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{selectedCredits.length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Group
|
||||
px="md"
|
||||
py="sm"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
bg="var(--mantine-color-edr-green-0)"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{selectedCredits.length} credit
|
||||
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
|
||||
{formatMoney(selectedTotal, selectedCredits[0].currency)}
|
||||
{" — "}
|
||||
{selectedCredits[0].shippingLineCompany?.name ?? ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
leftSection={<Receipt size={14} />}
|
||||
onClick={() => setInvoiceOpen(true)}
|
||||
>
|
||||
Generate invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credits match this filter."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load credits.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={invoiceOpen}
|
||||
onClose={() => setInvoiceOpen(false)}
|
||||
title="Generate invoice"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
One invoice for{" "}
|
||||
<Text component="span" fw={600} c="edr-text">
|
||||
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
|
||||
</Text>{" "}
|
||||
billing the selected credits. The line pays it at any CBE channel —
|
||||
there is no payment window.
|
||||
</Text>
|
||||
|
||||
<Stack gap={6}>
|
||||
{selectedCredits.map((credit) => (
|
||||
<Group key={credit.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" truncate>
|
||||
{credit.booking?.reference ?? credit.description ?? credit.id}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMoney(Number(credit.amount), credit.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(
|
||||
selectedTotal,
|
||||
selectedCredits[0]?.currency ?? "ETB",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<NumberInput
|
||||
label="Due in days"
|
||||
description="Optional — defaults to the standard invoice term."
|
||||
placeholder="14"
|
||||
min={1}
|
||||
value={dueInDays}
|
||||
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setInvoiceOpen(false)}
|
||||
disabled={isInvoicing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isInvoicing}
|
||||
onClick={() =>
|
||||
generateInvoice({
|
||||
creditIds: selectedCredits.map((c) => c.id),
|
||||
...(typeof dueInDays === "number"
|
||||
? { dueInDays }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Generate & issue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
|
||||
// Booking window for the schedule being created: off = inherit the live global
|
||||
// rules (the default), on = the values in `windowForm` are frozen onto it.
|
||||
const [configureWindow, setConfigureWindow] = useState(false);
|
||||
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
enabled: Boolean(routeId),
|
||||
}),
|
||||
);
|
||||
// For the create modal's dedication picker. 100 covers every line EDR deals
|
||||
// with; fetched only while the modal is open.
|
||||
const shippingLinesQuery = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
enabled: createOpen,
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const dispatchSchedule = useMutation(
|
||||
api.trainScheduling.dispatchSchedule.mutationOptions(),
|
||||
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
setWindowForm(null);
|
||||
setCreateOpen(false);
|
||||
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
placeholder="None — normal customer train"
|
||||
clearable
|
||||
searchable
|
||||
data={(shippingLinesQuery.data?.items ?? [])
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({ value: line.id, label: line.name }))}
|
||||
value={shippingLineCompanyId || null}
|
||||
onChange={(v) => setShippingLineCompanyId(v ?? "")}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Reverse wagon order"
|
||||
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."
|
||||
|
||||
@@ -44,6 +44,20 @@ import type {
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
RuleEngineListResult,
|
||||
RuleEngineRecord,
|
||||
@@ -156,6 +170,8 @@ import {
|
||||
import { containerTypesService } from "./container-types.service";
|
||||
import { containerService, type Container } from "./containerService";
|
||||
import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
@@ -2792,6 +2808,160 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCompanies: {
|
||||
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
|
||||
"shippingLineCompanies",
|
||||
"list",
|
||||
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
|
||||
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, ShippingLineCompany>(
|
||||
"shippingLineCompanies",
|
||||
"getById",
|
||||
({ id }) => shippingLineCompaniesService.getById(id),
|
||||
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
|
||||
),
|
||||
|
||||
register: endpoint<
|
||||
CreateShippingLineCompanyDto,
|
||||
RegisterShippingLineCompanyResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"register",
|
||||
(dto) => shippingLineCompaniesService.register(dto),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
|
||||
),
|
||||
|
||||
resendActivation: endpoint<
|
||||
{ id: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"resendActivation",
|
||||
({ id, channel }) =>
|
||||
shippingLineCompaniesService.resendActivation(id, channel),
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCredits: {
|
||||
summary: endpoint<{ shippingLineId?: string }, OutstandingTotals>(
|
||||
"shippingLineCredits",
|
||||
"summary",
|
||||
({ shippingLineId }) => shippingLineCreditsService.summary(shippingLineId),
|
||||
({ shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.summary(shippingLineId),
|
||||
),
|
||||
|
||||
list: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedShippingLineCredits
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"list",
|
||||
(filter) => shippingLineCreditsService.list(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.list(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
generateInvoice: endpoint<
|
||||
{ creditIds: string[]; dueInDays?: number },
|
||||
GeneratedCreditInvoice
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"generateInvoice",
|
||||
({ creditIds, dueInDays }) =>
|
||||
shippingLineCreditsService.generateInvoice(creditIds, dueInDays),
|
||||
undefined,
|
||||
// Billing a batch changes ledger rows, the summary totals and (via the
|
||||
// draft invoice) the invoices list.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
|
||||
listInvoices: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedCreditInvoices
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"listInvoices",
|
||||
(filter) => shippingLineCreditsService.listInvoices(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.invoices(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
pendingInvoiceActions: endpoint<
|
||||
{ invoiceIds: string[] },
|
||||
CreditInvoicePendingAction[]
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"pendingInvoiceActions",
|
||||
({ invoiceIds }) =>
|
||||
shippingLineCreditsService.pendingInvoiceActions(invoiceIds),
|
||||
({ invoiceIds }) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"pending-actions",
|
||||
[...invoiceIds].sort().join(","),
|
||||
] as const,
|
||||
),
|
||||
|
||||
requestInvoiceAction: endpoint<
|
||||
{
|
||||
invoiceId: string;
|
||||
action: "MARK_PAID" | "CANCEL";
|
||||
reason: string;
|
||||
paymentReference?: string;
|
||||
},
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"requestInvoiceAction",
|
||||
({ invoiceId, action, reason, paymentReference }) =>
|
||||
shippingLineCreditsService.requestInvoiceAction(
|
||||
invoiceId,
|
||||
action,
|
||||
reason,
|
||||
paymentReference,
|
||||
),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT],
|
||||
),
|
||||
|
||||
decideInvoiceAction: endpoint<
|
||||
{ approvalId: string; approve: boolean; note?: string },
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"decideInvoiceAction",
|
||||
({ approvalId, approve, note }) =>
|
||||
shippingLineCreditsService.decideInvoiceAction(approvalId, approve, note),
|
||||
undefined,
|
||||
// Approving executes a billing action, so both surfaces move.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
},
|
||||
|
||||
customers: {
|
||||
stats: endpoint<Record<string, never>, CompanyStats>(
|
||||
"customers",
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* Rates only: "true" lists shipping-line rates, "false" standard customer
|
||||
* ones. Omitted lists both.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -214,6 +219,7 @@ export const ruleEngineService = {
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
isShippingLineRate: params?.isShippingLineRate,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
export const shippingLineCompaniesService = {
|
||||
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCompanies>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
{ params: { page, limit } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<ShippingLineCompany> {
|
||||
return apiClient
|
||||
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the carrier's account and record, then sends an activation link.
|
||||
* Staff never set or see a password — the line chooses its own from the link.
|
||||
*/
|
||||
register(
|
||||
dto: CreateShippingLineCompanyDto,
|
||||
): Promise<RegisterShippingLineCompanyResult> {
|
||||
return apiClient
|
||||
.post<RegisterShippingLineCompanyResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
dto,
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
resendActivation(
|
||||
id: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
export interface ShippingLineCreditListFilter {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
/** Narrow to one line; omit for all lines. */
|
||||
shippingLineId?: string;
|
||||
}
|
||||
|
||||
export const shippingLineCreditsService = {
|
||||
/** Outstanding totals — every line, or one line when an id is given. */
|
||||
summary(shippingLineId?: string): Promise<OutstandingTotals> {
|
||||
return apiClient
|
||||
.get<OutstandingTotals>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.SUMMARY, {
|
||||
params: shippingLineId ? { shippingLineId } : {},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** The whole credit ledger, newest first, optionally filtered. */
|
||||
list(
|
||||
filter: ShippingLineCreditListFilter = {},
|
||||
): Promise<PaginatedShippingLineCredits> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCredits>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bill a batch of unbilled credits as one invoice. The API enforces that all
|
||||
* credits belong to one shipping line and share one currency.
|
||||
*/
|
||||
generateInvoice(
|
||||
creditIds: string[],
|
||||
dueInDays?: number,
|
||||
): Promise<GeneratedCreditInvoice> {
|
||||
return apiClient
|
||||
.post<GeneratedCreditInvoice>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICE, {
|
||||
creditIds,
|
||||
...(dueInDays ? { dueInDays } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Credit invoices with any pending manual-action request attached. */
|
||||
listInvoices(filter: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
} = {}): Promise<PaginatedCreditInvoices> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedCreditInvoices>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICES, {
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Undecided manual-action requests for a batch of invoice ids. */
|
||||
pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<CreditInvoicePendingAction[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return apiClient
|
||||
.get<CreditInvoicePendingAction[]>(
|
||||
`${URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE}/invoice-actions/pending`,
|
||||
{ params: { invoiceIds: invoiceIds.join(",") } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Maker step: raise a mark-paid or cancel request on a credit invoice. */
|
||||
requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: "MARK_PAID" | "CANCEL",
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url =
|
||||
action === "MARK_PAID"
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.MARK_PAID_REQUEST(invoiceId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.CANCEL_REQUEST(invoiceId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, {
|
||||
reason,
|
||||
...(paymentReference ? { paymentReference } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Decision step: approve (executes) or reject a pending request. */
|
||||
decideInvoiceAction(
|
||||
approvalId: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url = approve
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.APPROVE_ACTION(approvalId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.REJECT_ACTION(approvalId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, note ? { note } : {})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -90,6 +90,9 @@ export interface BookingContainerLine {
|
||||
containerNumber?: string | null;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
/** How many of this line are hazardous / refrigerated — 0 when none. */
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
containerType?: {
|
||||
id: string;
|
||||
code?: string;
|
||||
@@ -199,6 +202,7 @@ export interface BookingDetail {
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
isHazardous: boolean;
|
||||
isReefer?: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
priorityScore: number;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ResetChannel, ResetPasswordResult } from "./customer";
|
||||
|
||||
export type ShippingLineStatus = "active" | "suspended";
|
||||
|
||||
/**
|
||||
* A carrier with its own portal login, registered by staff.
|
||||
*
|
||||
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
|
||||
* that is a pricing lookup — a code/label a booking points at — with no account
|
||||
* and no login. This one is the account.
|
||||
*/
|
||||
export interface ShippingLineCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
imoNumber: string | null;
|
||||
bicCode: string | null;
|
||||
status: ShippingLineStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateShippingLineCompanyDto {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
scacCode?: string;
|
||||
imoNumber?: string;
|
||||
bicCode?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface RegisterShippingLineCompanyResult {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/**
|
||||
* Masked destination the activation link went to, or null when delivery
|
||||
* failed. The registration still succeeded — the link is resendable.
|
||||
*/
|
||||
activationSentTo: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCompanies {
|
||||
items: ShippingLineCompany[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type { ResetChannel, ResetPasswordResult };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
* Mirrors `shipping-line-credits` API responses.
|
||||
*/
|
||||
|
||||
export type ShippingLineCreditStatus =
|
||||
| "UNBILLED"
|
||||
| "BILLED"
|
||||
| "PAID"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface ShippingLineCredit {
|
||||
id: string;
|
||||
shippingLineCompanyId: string;
|
||||
bookingId: string;
|
||||
/** Numeric column — serialized as a string by the API. */
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: ShippingLineCreditStatus;
|
||||
description: string | null;
|
||||
invoiceId: string | null;
|
||||
billedAt: string | null;
|
||||
paidAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
cancellationReason: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string } | null;
|
||||
invoice?: { id: string; invoiceNumber: string } | null;
|
||||
shippingLineCompany?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
unbilledAmount: number;
|
||||
billedAmount: number;
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCredits {
|
||||
items: ShippingLineCredit[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** The invoice minted from a batch of unbilled credits (subset of fields). */
|
||||
export interface GeneratedCreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
totalAmount: string | number;
|
||||
currency: string;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
export type CreditInvoiceActionType = "MARK_PAID" | "CANCEL";
|
||||
export type CreditInvoiceActionStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
/** An undecided manual-action request attached to a credit invoice. */
|
||||
export interface CreditInvoicePendingAction {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
action: CreditInvoiceActionType;
|
||||
status: CreditInvoiceActionStatus;
|
||||
requestedBy: string;
|
||||
reason: string;
|
||||
paymentReference: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A credit invoice row in the staff list, enriched by the API. */
|
||||
export interface CreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
shippingLineCompanyId: string | null;
|
||||
shippingLineName: string | null;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
export interface PaginatedCreditInvoices {
|
||||
items: CreditInvoice[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -960,6 +960,11 @@ export interface CreateTrainSchedulePayload {
|
||||
maxWagonsPerTrain?: number;
|
||||
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
||||
reverseWagonOrder?: boolean;
|
||||
/**
|
||||
* Dedicate this departure to one shipping line — hidden from customers,
|
||||
* visible only to that line in its portal. Omit for a normal customer train.
|
||||
*/
|
||||
shippingLineCompanyId?: string;
|
||||
/**
|
||||
* Configure the booking window for THIS schedule instead of inheriting the
|
||||
* live global rules. Omit to follow the global rules (the default).
|
||||
|
||||
@@ -59,6 +59,15 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import {
|
||||
ShippingLineBookingDetailPage,
|
||||
ShippingLineBookingsPage,
|
||||
ShippingLineCompletePage,
|
||||
ShippingLineHelpPage,
|
||||
ShippingLineHomePage,
|
||||
ShippingLineInvoicesPage,
|
||||
ShippingLineSettingsPage,
|
||||
} from "./pages/shipping-line";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -129,12 +138,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* can be dismissed to use those pages. Visiting any other page bounces back to
|
||||
* home and re-opens the wizard. New users (no company yet) are treated the same
|
||||
* as users who haven't completed onboarding.
|
||||
*
|
||||
* Shipping lines are exempt: staff register them with their details already
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
// Keyed off a positive shipping-line identification, never off "no company":
|
||||
// that is also true mid-fetch and on error, which would let customers slip
|
||||
// past onboarding whenever the request failed.
|
||||
const needsOnboarding = isShippingLine
|
||||
? false
|
||||
: !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
@@ -176,21 +193,69 @@ function OnboardingGate() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-only routes. A shipping line that lands on one (an old link, a
|
||||
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
|
||||
* contract/company-shaped page that has no meaning for it.
|
||||
*/
|
||||
function RequireCustomer() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||
// throw the user into the wrong app.
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
|
||||
function RequireShippingLine() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (!isShippingLine) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||
* Waits for the company query: `isShippingLine` is false while that request is
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
function RedirectIfAuthed() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Landing page for visitors; authenticated users go straight to the portal. */
|
||||
function LandingRoute() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <EDRFreightLandingPage />;
|
||||
}
|
||||
|
||||
@@ -230,6 +295,37 @@ const sidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
|
||||
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
|
||||
* Bookings / Invoices pages are different pages at different routes.
|
||||
*/
|
||||
const shippingLineSidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/shipping-line/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
href: "/shipping-line/invoices",
|
||||
icon: <Receipt size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Settings",
|
||||
href: "/shipping-line/settings",
|
||||
icon: <Settings size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Help & Support",
|
||||
href: "/shipping-line/help",
|
||||
icon: <LifeBuoy size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -310,81 +406,152 @@ const App = () => {
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
{/* Shipping-line app. Its own layout and sidebar, and its own pages
|
||||
at their own routes — nothing here is shared with the customer
|
||||
branch below beyond the shell component itself. Contracts are
|
||||
absent by design: shipping lines request bookings directly. */}
|
||||
<Route element={<RequireShippingLine />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={shippingLineSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
// Support chat is company-scoped; a shipping line has no
|
||||
// company, so every poll would 403.
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/shipping-line"
|
||||
element={<ShippingLineHomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id"
|
||||
element={<ShippingLineBookingDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id/complete"
|
||||
element={<ShippingLineCompletePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
/>
|
||||
{/* Same detail component as the customer's /billing/:id — the
|
||||
API scopes my-invoices to the signed-in payer either way,
|
||||
and the page derives its back target from the URL. */}
|
||||
<Route
|
||||
path="/shipping-line/invoices/:id"
|
||||
element={<InvoiceDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/help"
|
||||
element={<ShippingLineHelpPage />}
|
||||
/>
|
||||
{/* Old shared links land on the shipping-line equivalents. */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Navigate to="/shipping-line/settings" replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
browsable here. New-booking entry still routes via a contract. */}
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/edit"
|
||||
element={<EditBookingPage />}
|
||||
/>
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
clearance — same form, submits to the complete endpoint. */}
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user