mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: implement shipping line bookings management
- Add ShippingLineBookingsPage for listing and managing shipping line bookings. - Create ShippingLineDocumentsModal for document uploads related to bookings. - Introduce ShippingLineInitiateModal for initiating new shipping line bookings. - Implement booking document state management with booking-doc-state utility. - Add shipping line bookings service for API interactions. - Update index to export new components and services. - Enhance types for freight to include shipping line credits.
This commit is contained in:
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
forwardRef(() => PaymentModule),
|
forwardRef(() => PaymentModule),
|
||||||
CompaniesModule,
|
// Cycles back via ShippingLineCompaniesModule, which imports this module.
|
||||||
|
forwardRef(() => CompaniesModule),
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
|
|||||||
exports: [BillingService],
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|
||||||
@@ -112,8 +112,16 @@ export interface GenerateInvoiceInput {
|
|||||||
sourceId: string;
|
sourceId: string;
|
||||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||||
type: string;
|
type: string;
|
||||||
companyId: string;
|
/** The customer billed. Omit only when billing a shipping line instead. */
|
||||||
companyProfileId: string;
|
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[];
|
lines: InvoiceLineInput[];
|
||||||
currency?: string;
|
currency?: string;
|
||||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||||
@@ -139,8 +147,11 @@ export interface InvoiceEventPayload {
|
|||||||
source: Freight.InvoiceSource;
|
source: Freight.InvoiceSource;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
type: string;
|
type: string;
|
||||||
companyId: string;
|
/** Null when the payer is a shipping line rather than a customer company. */
|
||||||
companyProfileId: string;
|
companyId: string | null;
|
||||||
|
companyProfileId: string | null;
|
||||||
|
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
|
||||||
|
shippingLineCompanyId?: string | null;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
status: Freight.InvoiceStatus;
|
status: Freight.InvoiceStatus;
|
||||||
@@ -609,7 +620,6 @@ export class BillingService {
|
|||||||
input: GenerateInvoiceInput,
|
input: GenerateInvoiceInput,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
console.log("oooooooooo", input);
|
|
||||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||||
}
|
}
|
||||||
@@ -622,6 +632,21 @@ export class BillingService {
|
|||||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
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 lines = input.lines.map((l) => {
|
||||||
const quantity = l.quantity ?? 1;
|
const quantity = l.quantity ?? 1;
|
||||||
const unitRate = l.unitRate ?? 0;
|
const unitRate = l.unitRate ?? 0;
|
||||||
@@ -657,8 +682,9 @@ export class BillingService {
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
sourceId: input.sourceId,
|
sourceId: input.sourceId,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
companyId: input.companyId,
|
companyId: input.companyId ?? null,
|
||||||
companyProfileId: input.companyProfileId,
|
companyProfileId: input.companyProfileId ?? null,
|
||||||
|
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
|
||||||
subtotalAmount: round2(subtotalAmount),
|
subtotalAmount: round2(subtotalAmount),
|
||||||
taxAmount: round2(taxAmount),
|
taxAmount: round2(taxAmount),
|
||||||
totalAmount: round2(totalAmount),
|
totalAmount: round2(totalAmount),
|
||||||
@@ -988,6 +1014,7 @@ export class BillingService {
|
|||||||
type: invoice.type,
|
type: invoice.type,
|
||||||
companyId: invoice.companyId,
|
companyId: invoice.companyId,
|
||||||
companyProfileId: invoice.companyProfileId,
|
companyProfileId: invoice.companyProfileId,
|
||||||
|
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
|
||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
|
|||||||
@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||||
invoiceNumber!: string;
|
invoiceNumber!: string;
|
||||||
|
|
||||||
/** The customer (company) this invoice is billed to. */
|
/**
|
||||||
@Column({ name: "company_id", type: "uuid" })
|
* The customer (company) this invoice is billed to. Null on a shipping-line
|
||||||
companyId!: string;
|
* 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)
|
@ManyToOne(() => Company)
|
||||||
@JoinColumn({ name: "company_id" })
|
@JoinColumn({ name: "company_id" })
|
||||||
company?: Company;
|
company?: Company;
|
||||||
|
|
||||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||||
@Column({ name: "company_profile_id", type: "uuid" })
|
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
|
||||||
companyProfileId!: string;
|
companyProfileId!: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => CompanyProfile)
|
@ManyToOne(() => CompanyProfile)
|
||||||
@JoinColumn({ name: "company_profile_id" })
|
@JoinColumn({ name: "company_profile_id" })
|
||||||
companyProfile?: CompanyProfile;
|
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. */
|
/** 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 })
|
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||||
subtotalAmount!: number;
|
subtotalAmount!: number;
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export class BookingPricingService {
|
|||||||
total += line.amount;
|
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 rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||||
const usedRatesMap = new Map([...baseRates, ...mileRates].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,
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
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,
|
originYardId: booking.originYardId,
|
||||||
destinationYardId: booking.destinationYardId,
|
destinationYardId: booking.destinationYardId,
|
||||||
totalWagons,
|
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> {
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
@@ -512,7 +531,7 @@ export class BookingPricingService {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
blocked: string[];
|
blocked: string[];
|
||||||
}> {
|
}> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.liveRatesForBooking(booking);
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
@@ -713,7 +732,7 @@ export class BookingPricingService {
|
|||||||
return { lineItems: [], usedRates: [] };
|
return { lineItems: [], usedRates: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.liveRatesForBooking(booking);
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
|
|||||||
*/
|
*/
|
||||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
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. */
|
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||||
function operationFor(tradeDirection: string): Op | null {
|
function operationFor(tradeDirection: string): Op | null {
|
||||||
if (tradeDirection === 'IMPORT') return 'import';
|
if (tradeDirection === 'IMPORT') return 'import';
|
||||||
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
outputCode: string | null;
|
outputCode: string | null;
|
||||||
includesCustoms: boolean;
|
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
|
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||||
// created with customsClearingEnabled (copied from the contract). Contract
|
// created with customsClearingEnabled (copied from the contract). Contract
|
||||||
// bookings carry customsClearingEnabled even when the serviceType relation
|
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||||
|
|||||||
@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
// Every booking is billed to a company — government bookings bill to a seeded
|
// Every CUSTOMER booking is billed to a company — government bookings bill to
|
||||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
// a seeded government company (companies.kind = 'government'). NULL only on a
|
||||||
@Column({ name: 'company_id', type: 'uuid' })
|
// 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;
|
companyId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Company, { nullable: true })
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
company?: Company | null;
|
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
|
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
* 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;
|
* commercial bookings resolve it from trade direction / active mode;
|
||||||
* government bookings carry the explicitly-picked government profile.
|
* 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;
|
companyProfileId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||||
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||||
contractType!: string;
|
contractType!: string;
|
||||||
|
|
||||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => ServiceType)
|
@ManyToOne(() => ServiceType)
|
||||||
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||||
equipmentReturn!: string;
|
equipmentReturn!: string;
|
||||||
|
|
||||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||||
originYardId!: string;
|
originYardId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Yard)
|
@ManyToOne(() => Yard)
|
||||||
@JoinColumn({ name: 'origin_yard_id' })
|
@JoinColumn({ name: 'origin_yard_id' })
|
||||||
originYard?: Yard;
|
originYard?: Yard;
|
||||||
|
|
||||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||||
destinationYardId!: string;
|
destinationYardId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Yard)
|
@ManyToOne(() => Yard)
|
||||||
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||||
tradeDirection!: string;
|
tradeDirection!: string;
|
||||||
|
|
||||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||||
freightType!: string;
|
freightType!: string;
|
||||||
|
|
||||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
// Fayda identity verification for the company's owner and PoA.
|
// Fayda identity verification for the company's owner and PoA.
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
||||||
// shipping-line session, which has no company row to look up.
|
// shipping-line session, which has no company row to look up. forwardRef
|
||||||
ShippingLineCompaniesModule,
|
// because that module imports BillingModule, which imports this one.
|
||||||
|
forwardRef(() => ShippingLineCompaniesModule),
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export class CreateRateDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationYardId?: string;
|
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 })
|
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CURRENCIES])
|
@IsIn([...CURRENCIES])
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
trigger?: string;
|
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 {
|
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
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 { CargoType } from './cargo-type.entity';
|
||||||
import { ContainerType } from './container-type.entity';
|
import { ContainerType } from './container-type.entity';
|
||||||
import { Yard } from './yard.entity';
|
import { Yard } from './yard.entity';
|
||||||
@@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
@Index(['trigger'])
|
@Index(['trigger'])
|
||||||
@Index(['originYardId'])
|
@Index(['originYardId'])
|
||||||
@Index(['destinationYardId'])
|
@Index(['destinationYardId'])
|
||||||
|
@Index(['shippingLineCompanyId'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||||
rateType!: RateType;
|
rateType!: RateType;
|
||||||
@@ -155,6 +157,23 @@ export class Rate extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'destination_yard_id' })
|
@JoinColumn({ name: 'destination_yard_id' })
|
||||||
destinationYard?: Yard | null;
|
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 })
|
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface IRatesRepository {
|
|||||||
rateType: string;
|
rateType: string;
|
||||||
/** Omitted for singly-resolved rates — see the repository implementation. */
|
/** Omitted for singly-resolved rates — see the repository implementation. */
|
||||||
rateUnit?: string;
|
rateUnit?: string;
|
||||||
|
/** Owning shipping line; null/omitted = the standard customer rate. */
|
||||||
|
shippingLineCompanyId?: string | null;
|
||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
findByPattern(pattern: {
|
findByPattern(pattern: {
|
||||||
rateType: string;
|
rateType: string;
|
||||||
rateUnit?: string;
|
rateUnit?: string;
|
||||||
|
shippingLineCompanyId?: string | null;
|
||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
@@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
|
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) {
|
if (pattern.containerTypeId) {
|
||||||
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
||||||
} else {
|
} else {
|
||||||
@@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
// yards joined the route columns have only ids to render.
|
// yards joined the route columns have only ids to render.
|
||||||
.leftJoinAndSelect('rate.originYard', 'originYard')
|
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
.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');
|
.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) {
|
if (query.status) {
|
||||||
qb.andWhere('rate.status = :status', { status: query.status });
|
qb.andWhere('rate.status = :status', { status: query.status });
|
||||||
}
|
}
|
||||||
@@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
}
|
}
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
qb.andWhere(
|
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}%` },
|
{ search: `%${query.search}%` },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
|
|||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
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 { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||||
|
|
||||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
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
|
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
|
||||||
// against them (a cap above the rating is a typo, not a policy).
|
// against them (a cap above the rating is a typo, not a policy).
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
|
// Rates may be scoped to one shipping line; creating such a rate validates
|
||||||
|
// the line exists and is active.
|
||||||
|
ShippingLineCompaniesModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
CargoTypesController,
|
CargoTypesController,
|
||||||
|
|||||||
@@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
|||||||
expect(fuelMods(result)).toHaveLength(0);
|
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;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
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
|
* 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.
|
* 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.
|
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
|
||||||
// from a non-idempotent seeder — would otherwise repeat the same surcharge
|
// 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.
|
// 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(
|
const surchargeRates = this.dedupeRatesBySignature(
|
||||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||||
);
|
);
|
||||||
@@ -812,6 +825,28 @@ export class RuleEngineService {
|
|||||||
return rate.rateType ?? rate.trigger;
|
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.
|
* Collapse rates that describe the same charge to a single representative.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => {
|
|||||||
})),
|
})),
|
||||||
} as never,
|
} as never,
|
||||||
{ findById: jest.fn().mockResolvedValue(null) } 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';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
import { IsNull, Not } from 'typeorm';
|
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 { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
@@ -39,6 +41,7 @@ export class RatesService {
|
|||||||
private readonly yardsRepository: IYardsRepository,
|
private readonly yardsRepository: IYardsRepository,
|
||||||
@Inject(CARGO_TYPES_REPOSITORY)
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||||
|
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List rates — standard paginated envelope with server-side search. */
|
/** List rates — standard paginated envelope with server-side search. */
|
||||||
@@ -491,6 +494,7 @@ export class RatesService {
|
|||||||
rateType: string;
|
rateType: string;
|
||||||
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
||||||
rateUnit?: string;
|
rateUnit?: string;
|
||||||
|
shippingLineCompanyId: string | null;
|
||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
tradeDirection: 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. */
|
/** Create a rate in DRAFT status. */
|
||||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||||
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
||||||
@@ -568,6 +601,11 @@ export class RatesService {
|
|||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shippingLineCompanyId = await this.resolveShippingLineScope(
|
||||||
|
dto.shippingLineCompanyId,
|
||||||
|
tradeDirection,
|
||||||
|
);
|
||||||
|
|
||||||
const rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -602,6 +640,7 @@ export class RatesService {
|
|||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||||
|
shippingLineCompanyId,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
@@ -614,6 +653,7 @@ export class RatesService {
|
|||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
rateType,
|
rateType,
|
||||||
|
shippingLineCompanyId,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
@@ -791,6 +831,17 @@ export class RatesService {
|
|||||||
updates.originYardId = yardScope.originYardId;
|
updates.originYardId = yardScope.originYardId;
|
||||||
updates.destinationYardId = yardScope.destinationYardId;
|
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.
|
// Keep the derived rateType in sync with whatever changed.
|
||||||
const rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
@@ -839,6 +890,7 @@ export class RatesService {
|
|||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||||
|
shippingLineCompanyId,
|
||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
tradeDirection: updates.tradeDirection,
|
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,38 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,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,96 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,363 @@
|
|||||||
|
import { insertWithGeneratedReference } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { In, 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 { ServiceType } from "../rule-engine/entities/service-type.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] = 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" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
// Bare instance — filled in when the booking is completed.
|
||||||
|
scheduledDate: null,
|
||||||
|
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.
|
||||||
|
relations: { originYard: true, destinationYard: true, serviceType: 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" } });
|
||||||
|
|
||||||
|
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,55 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
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 { OtpModule } from "../otp/otp.module";
|
||||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||||
|
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||||
|
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
||||||
|
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ShippingLineCompany, User]),
|
// 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,
|
||||||
|
User,
|
||||||
|
Booking,
|
||||||
|
]),
|
||||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||||
FreightAuthModule,
|
FreightAuthModule,
|
||||||
OtpModule,
|
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],
|
controllers: [
|
||||||
providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository],
|
ShippingLineCompaniesController,
|
||||||
exports: [ShippingLineCompaniesService],
|
ShippingLineBookingsController,
|
||||||
|
ShippingLineCreditsController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ShippingLineCompaniesService,
|
||||||
|
ShippingLineCompaniesRepository,
|
||||||
|
ShippingLineBookingsService,
|
||||||
|
ShippingLineCreditsService,
|
||||||
|
ShippingLineCreditsRepository,
|
||||||
|
],
|
||||||
|
// Exported so whatever prices a shipping-line booking can record the charge.
|
||||||
|
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
|
||||||
})
|
})
|
||||||
export class ShippingLineCompaniesModule {}
|
export class ShippingLineCompaniesModule {}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import {
|
||||||
|
CancelCreditDto,
|
||||||
|
GenerateCreditInvoiceDto,
|
||||||
|
} from "./dto/shipping-line-credit.dto";
|
||||||
|
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.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) {}
|
||||||
|
|
||||||
|
// 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,138 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
async outstandingFor(
|
||||||
|
shippingLineCompanyId: string,
|
||||||
|
): Promise<OutstandingTotals> {
|
||||||
|
const rows = await this.credits
|
||||||
|
.createQueryBuilder("credit")
|
||||||
|
.select("credit.status", "status")
|
||||||
|
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
|
||||||
|
.addSelect("COUNT(*)", "count")
|
||||||
|
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
|
||||||
|
shippingLineCompanyId,
|
||||||
|
})
|
||||||
|
.andWhere("credit.status IN (:...statuses)", {
|
||||||
|
statuses: [...OUTSTANDING_CREDIT_STATUSES],
|
||||||
|
})
|
||||||
|
.andWhere("credit.deletedAt IS NULL")
|
||||||
|
.groupBy("credit.status")
|
||||||
|
.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 for one line — every credit, whatever its status. */
|
||||||
|
findAllPaginated(
|
||||||
|
shippingLineCompanyId: string,
|
||||||
|
skip: number,
|
||||||
|
take: number,
|
||||||
|
status?: ShippingLineCreditStatus,
|
||||||
|
): Promise<[ShippingLineCredit[], number]> {
|
||||||
|
return this.credits.findAndCount({
|
||||||
|
where: {
|
||||||
|
shippingLineCompanyId,
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
},
|
||||||
|
relations: { booking: true, invoice: true },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
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,
|
||||||
|
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,420 @@
|
|||||||
|
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 { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,9 @@ interface InventoryContext {
|
|||||||
interface ViewSource {
|
interface ViewSource {
|
||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: 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;
|
sourceId: string;
|
||||||
type: string;
|
type: string;
|
||||||
status: Freight.InvoiceStatus | 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 ───────────────────────────────────────────────
|
// ── Hazardous cargo documents ───────────────────────────────────────────────
|
||||||
// Asked for in the contract wizard the moment the customer flags the cargo as
|
// 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
|
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
|
||||||
@@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder {
|
|||||||
description:
|
description:
|
||||||
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
|
"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));
|
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||||
|
|||||||
@@ -513,6 +513,25 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:confirm_offline",
|
"edr_freight_app:invoices:confirm_offline",
|
||||||
"Confirm offline (bank transfer) invoice payment",
|
"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",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// E. First / last mile operations
|
// E. First / last mile operations
|
||||||
@@ -1730,6 +1749,13 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:shipping_lines:update",
|
update: "edr_freight_app:shipping_lines:update",
|
||||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
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",
|
||||||
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: "edr_freight_app:payments:view",
|
view: "edr_freight_app:payments:view",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -257,6 +257,34 @@ const RuleEngineFormDialog = ({
|
|||||||
next.cargoTypeId = "";
|
next.cargoTypeId = "";
|
||||||
next.rateUnit = "";
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -347,12 +375,23 @@ const RuleEngineFormDialog = ({
|
|||||||
borderRadius: "var(--mantine-radius-md)",
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Stack gap={2}>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{field.description ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{field.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(values[field.name])}
|
checked={Boolean(values[field.name])}
|
||||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
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"
|
size="md"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
/>
|
/>
|
||||||
@@ -493,6 +532,12 @@ const RuleEngineFormDialog = ({
|
|||||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||||
// the choices track the other fields the admin has picked.
|
// the choices track the other fields the admin has picked.
|
||||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
key={field.name}
|
key={field.name}
|
||||||
@@ -501,9 +546,18 @@ const RuleEngineFormDialog = ({
|
|||||||
placeholder={
|
placeholder={
|
||||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
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)}
|
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
|
// Mantine's Select is not a native input, so `required` only marks it
|
||||||
// visually — handleSubmit is what actually blocks an empty one.
|
// visually — handleSubmit is what actually blocks an empty one.
|
||||||
required={field.required}
|
required={field.required}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
|
||||||
|
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
|
||||||
import {
|
import {
|
||||||
ruleEngineService,
|
ruleEngineService,
|
||||||
type RuleEngineListParams,
|
type RuleEngineListParams,
|
||||||
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
|
|||||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
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
|
* Approval-step role options, sourced from the live IAM position types. The
|
||||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||||
|
|||||||
@@ -136,9 +136,20 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
clearance?.milestones?.some(
|
clearance?.milestones?.some(
|
||||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
const queriesLocked = Boolean(
|
// Querying a document is only possible while the booking is actually in
|
||||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
// 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 =
|
const workflowFiles =
|
||||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
useCargoTypeParentOptions,
|
useCargoTypeParentOptions,
|
||||||
useContainerTypeOptions,
|
useContainerTypeOptions,
|
||||||
useLiveRateOptions,
|
useLiveRateOptions,
|
||||||
|
useShippingLineCompanyOptions,
|
||||||
useWagonTypeOptions,
|
useWagonTypeOptions,
|
||||||
useYardOptions,
|
useYardOptions,
|
||||||
type YardOption,
|
type YardOption,
|
||||||
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
|
|||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
end: "origin" | "destination",
|
end: "origin" | "destination",
|
||||||
): { label: string; value: string }[] => {
|
): { 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 ?? "");
|
const appliesTo = String(values.appliesTo ?? "");
|
||||||
let country: string | undefined;
|
let country: string | undefined;
|
||||||
if (appliesTo === "INTERCITY") {
|
if (appliesTo === "INTERCITY") {
|
||||||
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
useContainerTypeOptions(false, usesContainerTypeField);
|
useContainerTypeOptions(false, usesContainerTypeField);
|
||||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||||
useLiveRateOptions(usesLiveRateField);
|
useLiveRateOptions(usesLiveRateField);
|
||||||
|
const usesShippingLineField = Boolean(
|
||||||
|
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: shippingLineOptions,
|
||||||
|
isLoading: shippingLineOptionsLoading,
|
||||||
|
} = useShippingLineCompanyOptions(usesShippingLineField);
|
||||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
useWagonTypeOptions(usesWagonTypeField);
|
useWagonTypeOptions(usesWagonTypeField);
|
||||||
const usesYardField = Boolean(
|
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") {
|
if (field.name === "rateId") {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||||
let payload = values;
|
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
|
// Base-freight categories have no surcharge trigger field — the engine
|
||||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||||
// chosen trigger.
|
// chosen trigger.
|
||||||
@@ -934,6 +995,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||||
(usesYardField && yardOptionsLoading) ||
|
(usesYardField && yardOptionsLoading) ||
|
||||||
|
(usesShippingLineField && shippingLineOptionsLoading) ||
|
||||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||||
}
|
}
|
||||||
positionOptions={!editing ? createPositionOptions : undefined}
|
positionOptions={!editing ? createPositionOptions : undefined}
|
||||||
|
|||||||
@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
|
|||||||
export interface RuleEngineListTab {
|
export interface RuleEngineListTab {
|
||||||
key: string;
|
key: string;
|
||||||
label: 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 {
|
export interface RuleEngineResourceConfig {
|
||||||
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
|
|||||||
{ label: "Bulk", value: "BULK" },
|
{ 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. */
|
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
["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).
|
* the empty-container return surcharge (sold per route + container type).
|
||||||
*/
|
*/
|
||||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
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" &&
|
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||||
|
|
||||||
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
|
|||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
cargoUnitOfMeasure = "",
|
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 appliesTo = String(values.appliesTo ?? "");
|
||||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||||
if (!appliesTo) return [];
|
if (!appliesTo) return [];
|
||||||
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
supportsSearch: true,
|
supportsSearch: true,
|
||||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||||
listTabs: [
|
listTabs: [
|
||||||
{ key: "all", label: "All", filters: {} },
|
// "All" and every shape tab show customer rates only — a shipping line's
|
||||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
// negotiated price is its own list, not an extra row in the standard one.
|
||||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
|
||||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
{
|
||||||
|
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",
|
key: "trucking",
|
||||||
label: "First / Last mile",
|
label: "First / Last mile",
|
||||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customs",
|
key: "customs",
|
||||||
label: "Customs clearance",
|
label: "Customs clearance",
|
||||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
label: "Container return",
|
label: "Container return",
|
||||||
filters: { trigger: "WITH_RETURN" },
|
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "surcharges",
|
key: "surcharges",
|
||||||
label: "Surcharges",
|
label: "Surcharges",
|
||||||
filters: {
|
filters: {
|
||||||
appliesTo: "OTHER",
|
appliesTo: "OTHER",
|
||||||
|
isShippingLineRate: "false",
|
||||||
trigger:
|
trigger:
|
||||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
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: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||||
// Base freight is priced per leg, so the route is what tells two otherwise
|
// 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" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
],
|
],
|
||||||
formFields: [
|
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",
|
name: "appliesTo",
|
||||||
label: "Applies to",
|
label: "Applies to",
|
||||||
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
options: RATE_APPLIES_TO,
|
options: RATE_APPLIES_TO,
|
||||||
description:
|
description:
|
||||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
"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 ──────────────────
|
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||||
{
|
{
|
||||||
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
options: RATE_TRIGGERS,
|
options: RATE_TRIGGERS,
|
||||||
placeholder: "What makes this surcharge apply?",
|
placeholder: "What makes this surcharge apply?",
|
||||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
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-
|
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||||
// scoped surcharges (customs clearance; empty-container return, which is
|
// scoped surcharges (customs clearance; empty-container return, which is
|
||||||
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
? FUEL_TRADE_DIRECTIONS
|
? FUEL_TRADE_DIRECTIONS
|
||||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
!isShippingLineRate(v) &&
|
||||||
|
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||||
String(v.trigger ?? ""),
|
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
|
// ── Cargo kind — customs clearance is priced separately for containers
|
||||||
// (one rate per container type) and bulk ───────────────────────────────
|
// (one rate per container type) and bulk ───────────────────────────────
|
||||||
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select container type (optional)",
|
placeholder: "Select container type (optional)",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "CONTAINER" ||
|
!isShippingLineRate(v) &&
|
||||||
|
(v.appliesTo === "CONTAINER" ||
|
||||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
(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 ─
|
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||||
{
|
{
|
||||||
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select bulk commodity (optional)",
|
placeholder: "Select bulk commodity (optional)",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "BULK" ||
|
!isShippingLineRate(v) &&
|
||||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
|
(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 ──────────────────────
|
// ── The leg this rate prices — base freight only ──────────────────────
|
||||||
// Options are narrowed to the countries the direction allows (import
|
// Options are narrowed to the countries the direction allows (import
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
|
|||||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||||
appliesTo?: string;
|
appliesTo?: string;
|
||||||
trigger?: string;
|
trigger?: string;
|
||||||
|
/**
|
||||||
|
* Rates only: "true" lists shipping-line rates, "false" standard customer
|
||||||
|
* ones. Omitted lists both.
|
||||||
|
*/
|
||||||
|
isShippingLineRate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleEngineReorderPayload {
|
export interface RuleEngineReorderPayload {
|
||||||
@@ -214,6 +219,7 @@ export const ruleEngineService = {
|
|||||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||||
appliesTo: params?.appliesTo,
|
appliesTo: params?.appliesTo,
|
||||||
trigger: params?.trigger,
|
trigger: params?.trigger,
|
||||||
|
isShippingLineRate: params?.isShippingLineRate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return normalizeList<T>(response.data, page, pageSize);
|
return normalizeList<T>(response.data, page, pageSize);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
|||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
import {
|
import {
|
||||||
|
ShippingLineBookingDetailPage,
|
||||||
ShippingLineBookingsPage,
|
ShippingLineBookingsPage,
|
||||||
ShippingLineHelpPage,
|
ShippingLineHelpPage,
|
||||||
ShippingLineHomePage,
|
ShippingLineHomePage,
|
||||||
@@ -418,6 +419,9 @@ const App = () => {
|
|||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
// Support chat is company-scoped; a shipping line has no
|
||||||
|
// company, so every poll would 403.
|
||||||
|
showSupportWidget={false}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -431,6 +435,10 @@ const App = () => {
|
|||||||
path="/shipping-line/bookings"
|
path="/shipping-line/bookings"
|
||||||
element={<ShippingLineBookingsPage />}
|
element={<ShippingLineBookingsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shipping-line/bookings/:id"
|
||||||
|
element={<ShippingLineBookingDetailPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/shipping-line/invoices"
|
path="/shipping-line/invoices"
|
||||||
element={<ShippingLineInvoicesPage />}
|
element={<ShippingLineInvoicesPage />}
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export interface AppLayoutProps {
|
|||||||
}[];
|
}[];
|
||||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||||
companyType?: string | null;
|
companyType?: string | null;
|
||||||
|
/**
|
||||||
|
* Render the floating support-chat launcher. Defaults to true so the customer
|
||||||
|
* portal is unaffected; shipping lines pass false — support chat is scoped to
|
||||||
|
* a company, which they do not have.
|
||||||
|
*/
|
||||||
|
showSupportWidget?: boolean;
|
||||||
/** Create a new service profile of the given type (with business license). */
|
/** Create a new service profile of the given type (with business license). */
|
||||||
onCreateProfile?: (
|
onCreateProfile?: (
|
||||||
type: ServiceType,
|
type: ServiceType,
|
||||||
@@ -152,6 +158,7 @@ export function AppLayout({
|
|||||||
companyType,
|
companyType,
|
||||||
onCreateProfile,
|
onCreateProfile,
|
||||||
onReapplyProfile,
|
onReapplyProfile,
|
||||||
|
showSupportWidget = true,
|
||||||
children,
|
children,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||||
@@ -859,8 +866,10 @@ export function AppLayout({
|
|||||||
{children}
|
{children}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
{/* Floating customer-support chat launcher. */}
|
{/* Floating customer-support chat launcher. Hidden when the caller opts
|
||||||
<SupportWidget />
|
out: support chat resolves the user's external profile → company, and
|
||||||
|
a shipping line has neither, so every poll would 403. */}
|
||||||
|
{showSupportWidget && <SupportWidget />}
|
||||||
|
|
||||||
{/* Create-profile modal — opens when switching to a mode the company
|
{/* Create-profile modal — opens when switching to a mode the company
|
||||||
doesn't have a profile for yet. */}
|
doesn't have a profile for yet. */}
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||||
|
import {
|
||||||
|
BodyGrid,
|
||||||
|
CardTitle,
|
||||||
|
PageShell,
|
||||||
|
SectionCard,
|
||||||
|
} from "@/pages/bookings/BookingDetailPage/components/layout";
|
||||||
|
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||||
|
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bookingDocState,
|
||||||
|
hasDocuments,
|
||||||
|
needsUpload,
|
||||||
|
DOC_STATE_ACTION_LABEL,
|
||||||
|
DOC_STATE_COLOR,
|
||||||
|
DOC_STATE_LABEL,
|
||||||
|
} from "./booking-doc-state";
|
||||||
|
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses a booking can be cancelled from — everything before it is priced.
|
||||||
|
* Mirrors SHIPPING_LINE_CANCELLABLE_STATUSES on the API.
|
||||||
|
*/
|
||||||
|
const CANCELLABLE_STATUSES = new Set([
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line booking detail.
|
||||||
|
*
|
||||||
|
* Shaped like the customer booking detail page — same shell, same two-column
|
||||||
|
* body — but split into Documents / Booking details tabs, since a bare
|
||||||
|
* shipping-line booking has little else to show until it is completed.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineBookingDetailPage() {
|
||||||
|
const { id = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [docsOpen, setDocsOpen] = useState(false);
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
|
const bookingQuery = useQuery({
|
||||||
|
queryKey: ["shipping-line-bookings", id],
|
||||||
|
queryFn: () => shippingLineBookingsService.getById(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelOpen(false);
|
||||||
|
setCancelReason("");
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["shipping-line-bookings"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bookingQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<Center py={80}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookingQuery.isError || !bookingQuery.data) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<Alert color="red">This booking could not be loaded.</Alert>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking: ShippingLineBooking = bookingQuery.data;
|
||||||
|
const status = booking.status as string;
|
||||||
|
const docState = bookingDocState(booking);
|
||||||
|
const showDocs = hasDocuments(docState);
|
||||||
|
const wantsUpload = needsUpload(docState);
|
||||||
|
const actionNeeded = docState === "ACTION_NEEDED";
|
||||||
|
|
||||||
|
// Mirrors the server's rule: cancellable only before the booking is priced.
|
||||||
|
// Kept in sync deliberately — a button the API would reject is worse than no
|
||||||
|
// button at all.
|
||||||
|
const canCancel =
|
||||||
|
CANCELLABLE_STATUSES.has(status) && !(Number(booking.totalAmount ?? 0) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
w="fit-content"
|
||||||
|
leftSection={<ArrowLeft size={15} />}
|
||||||
|
onClick={() => navigate("/shipping-line/bookings")}
|
||||||
|
>
|
||||||
|
Back to bookings
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Stack gap={8} miw={0}>
|
||||||
|
<Text fz="26px" fw={800} c="#10202F">
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
<Group gap={8}>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
{showDocs && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color={DOC_STATE_COLOR[docState]}
|
||||||
|
leftSection={
|
||||||
|
actionNeeded ? <AlertCircle size={12} /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DOC_STATE_LABEL[docState]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
|
>
|
||||||
|
Cancel booking
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showDocs && (
|
||||||
|
<Button
|
||||||
|
color={actionNeeded ? "red" : "edr-green"}
|
||||||
|
variant={wantsUpload ? "filled" : "light"}
|
||||||
|
radius="md"
|
||||||
|
leftSection={
|
||||||
|
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
|
||||||
|
}
|
||||||
|
onClick={() => setDocsOpen(true)}
|
||||||
|
>
|
||||||
|
{DOC_STATE_ACTION_LABEL[docState]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tabs defaultValue="documents" keepMounted={false}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="documents"
|
||||||
|
leftSection={<FileText size={15} />}
|
||||||
|
// The one place a query surfaces without opening the tab.
|
||||||
|
rightSection={
|
||||||
|
actionNeeded ? (
|
||||||
|
<Badge size="xs" circle variant="filled" color="red">
|
||||||
|
!
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="details" leftSection={<Package size={15} />}>
|
||||||
|
Booking details
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="documents" pt="lg">
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Documents</CardTitle>
|
||||||
|
|
||||||
|
<Stack gap="md" mt="sm">
|
||||||
|
{docState === "ACTION_NEEDED" ? (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={18} />}>
|
||||||
|
Operations returned one or more documents with a query. Open
|
||||||
|
the documents, read the note on each flagged item and upload a
|
||||||
|
corrected file — the booking cannot move on until you do.
|
||||||
|
</Alert>
|
||||||
|
) : docState === "AWAITING" ? (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
radius="md"
|
||||||
|
icon={<ClipboardList size={18} />}
|
||||||
|
>
|
||||||
|
This booking is waiting on your documents. Upload them for
|
||||||
|
Operations to review — the booking can be completed once they
|
||||||
|
are approved.
|
||||||
|
</Alert>
|
||||||
|
) : docState === "IN_REVIEW" ? (
|
||||||
|
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||||
|
Your documents are with Operations for review. You can still
|
||||||
|
open them, and replace any that come back with a query.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
color="teal"
|
||||||
|
radius="md"
|
||||||
|
icon={<CheckCircle2 size={18} />}
|
||||||
|
>
|
||||||
|
Your documents are approved.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color={actionNeeded ? "red" : "edr-green"}
|
||||||
|
variant={wantsUpload ? "filled" : "light"}
|
||||||
|
radius="md"
|
||||||
|
w="fit-content"
|
||||||
|
leftSection={
|
||||||
|
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
|
||||||
|
}
|
||||||
|
onClick={() => setDocsOpen(true)}
|
||||||
|
>
|
||||||
|
{DOC_STATE_ACTION_LABEL[docState]}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="details" pt="lg">
|
||||||
|
<BodyGrid
|
||||||
|
left={
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Shipment</CardTitle>
|
||||||
|
<Stack gap="xs" mt="sm">
|
||||||
|
{/* Set at initiate time from the chosen route, so it is
|
||||||
|
known before Operations reviews the documents. */}
|
||||||
|
<DetailRow
|
||||||
|
label="Route"
|
||||||
|
value={
|
||||||
|
booking.originYard || booking.destinationYard
|
||||||
|
? `${
|
||||||
|
booking.originYard?.label ??
|
||||||
|
booking.originYard?.code ??
|
||||||
|
"—"
|
||||||
|
} → ${
|
||||||
|
booking.destinationYard?.label ??
|
||||||
|
booking.destinationYard?.code ??
|
||||||
|
"—"
|
||||||
|
}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Freight type"
|
||||||
|
value={booking.freightType ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Trade direction"
|
||||||
|
value={booking.tradeDirection ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Shipment day"
|
||||||
|
value={
|
||||||
|
booking.scheduledDate
|
||||||
|
? new Date(booking.scheduledDate).toLocaleDateString()
|
||||||
|
: "Not scheduled yet"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Booking</CardTitle>
|
||||||
|
<Stack gap="xs" mt="sm">
|
||||||
|
<DetailRow label="Reference" value={booking.reference} />
|
||||||
|
<DetailRow label="Status" value={status} />
|
||||||
|
<DetailRow
|
||||||
|
label="Created"
|
||||||
|
value={
|
||||||
|
booking.createdAt
|
||||||
|
? new Date(booking.createdAt).toLocaleDateString()
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<ShippingLineDocumentsModal
|
||||||
|
booking={docsOpen ? booking : null}
|
||||||
|
onClose={() => setDocsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancelling is irreversible, so it asks first rather than firing on the
|
||||||
|
button press. The reason is optional but recorded. */}
|
||||||
|
<Modal
|
||||||
|
opened={cancelOpen}
|
||||||
|
onClose={() => setCancelOpen(false)}
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Cancel this booking?
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
Booking <strong>{booking.reference}</strong> will be cancelled. This
|
||||||
|
cannot be undone — you would need to initiate a new booking.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Reason (optional)"
|
||||||
|
placeholder="Why are you cancelling this booking?"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
maxRows={4}
|
||||||
|
maxLength={500}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cancelMutation.isError && (
|
||||||
|
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||||
|
{(cancelMutation.error as Error)?.message ??
|
||||||
|
"Could not cancel the booking."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setCancelOpen(false)}
|
||||||
|
>
|
||||||
|
Keep booking
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
loading={cancelMutation.isPending}
|
||||||
|
onClick={() => cancelMutation.mutate()}
|
||||||
|
>
|
||||||
|
Cancel booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" align="flex-start" gap="md" wrap="nowrap">
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} fw={600} ta="right">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,314 @@
|
|||||||
import { Package } from "lucide-react";
|
import {
|
||||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
type ColumnDef,
|
||||||
|
usePagination,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
MoreVertical,
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
/**
|
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||||
* Shipping-line bookings. Contracts do not apply to shipping lines, so a
|
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||||
* booking is requested directly here rather than being created against a
|
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||||
* contract the way the customer flow does it.
|
|
||||||
*/
|
import {
|
||||||
export default function ShippingLineBookingsPage() {
|
bookingDocState,
|
||||||
|
hasDocuments,
|
||||||
|
needsUpload,
|
||||||
|
DOC_STATE_ACTION_LABEL,
|
||||||
|
DOC_STATE_COLOR,
|
||||||
|
DOC_STATE_LABEL,
|
||||||
|
} from "./booking-doc-state";
|
||||||
|
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||||
|
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
||||||
|
|
||||||
|
function ColHeader({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<ShippingLinePlaceholder
|
<Text fz={12} fw={700} c="edr-muted" tt="uppercase" lts="0.04em">
|
||||||
title="Bookings"
|
{label}
|
||||||
description="Request and track your booking requests."
|
</Text>
|
||||||
icon={<Package size={28} className="text-slate-300" />}
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line bookings list.
|
||||||
|
*
|
||||||
|
* Mirrors the customer bookings list (same DataTable, status badge, row-click
|
||||||
|
* to detail and per-row action menu) so the two portals read the same. What
|
||||||
|
* differs is the flow behind it: contracts do not apply to shipping lines, so
|
||||||
|
* "Initiate booking" creates a bare booking directly rather than routing
|
||||||
|
* through a contract.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineBookingsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination();
|
||||||
|
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookingsQuery = useQuery({
|
||||||
|
queryKey: ["shipping-line-bookings"],
|
||||||
|
queryFn: shippingLineBookingsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [initiateOpen, setInitiateOpen] = useState(false);
|
||||||
|
|
||||||
|
const rows = useMemo(() => bookingsQuery.data ?? [], [bookingsQuery.data]);
|
||||||
|
|
||||||
|
const status = bookingsQuery.isLoading
|
||||||
|
? "loading"
|
||||||
|
: bookingsQuery.isError
|
||||||
|
? "error"
|
||||||
|
: "success";
|
||||||
|
const showEmpty = status === "success" && rows.length === 0;
|
||||||
|
|
||||||
|
const columns: ColumnDef<ShippingLineBooking>[] = [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: () => <ColHeader label="Booking" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fw={700} fz={14}>
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: () => <ColHeader label="Route" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
const origin = b.originYard?.label ?? b.originYard?.code;
|
||||||
|
const dest = b.destinationYard?.label ?? b.destinationYard?.code;
|
||||||
|
return (
|
||||||
|
<Text fz={13} c={origin && dest ? undefined : "edr-muted"}>
|
||||||
|
{origin && dest ? `${origin} → ${dest}` : "—"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <ColHeader label="Status" />,
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documents",
|
||||||
|
header: () => <ColHeader label="Documents" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const state = bookingDocState(row.original);
|
||||||
|
if (state === "NONE") return null;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color={DOC_STATE_COLOR[state]}
|
||||||
|
leftSection={
|
||||||
|
state === "ACTION_NEEDED" ? <AlertCircle size={12} /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DOC_STATE_LABEL[state]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
header: () => <ColHeader label="Created" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
{row.original.createdAt
|
||||||
|
? new Date(row.original.createdAt).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
size: 200,
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const booking = row.original;
|
||||||
|
const state = bookingDocState(booking);
|
||||||
|
const showDocs = hasDocuments(state);
|
||||||
|
const wantsUpload = needsUpload(state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={6}
|
||||||
|
justify="flex-end"
|
||||||
|
wrap="nowrap"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showDocs && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
// A queried document is the one case that needs to pull the
|
||||||
|
// eye — it is the only state where the shipping line is
|
||||||
|
// blocking its own booking.
|
||||||
|
color={state === "ACTION_NEEDED" ? "red" : "edr-green"}
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
leftSection={
|
||||||
|
wantsUpload ? <Upload size={14} /> : <FileText size={14} />
|
||||||
|
}
|
||||||
|
onClick={() => setDocsBooking(booking)}
|
||||||
|
>
|
||||||
|
{DOC_STATE_ACTION_LABEL[state]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
size={30}
|
||||||
|
radius="md"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} color="#9AA8B5" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/shipping-line/bookings/${booking.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</Menu.Item>
|
||||||
|
{showDocs && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
wantsUpload ? (
|
||||||
|
<Upload size={15} />
|
||||||
|
) : (
|
||||||
|
<FileText size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setDocsBooking(booking)}
|
||||||
|
>
|
||||||
|
{DOC_STATE_ACTION_LABEL[state]}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||||
|
<Box>
|
||||||
|
<Title
|
||||||
|
order={1}
|
||||||
|
fw={800}
|
||||||
|
fz={26}
|
||||||
|
style={{ letterSpacing: "-0.01em" }}
|
||||||
|
>
|
||||||
|
Bookings
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="edr-muted" mt={4}>
|
||||||
|
Request a booking directly — no contract required.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setInitiateOpen(true)}
|
||||||
|
>
|
||||||
|
Initiate booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card radius={16} p={0} withBorder style={{ borderColor: "#E6ECF2" }}>
|
||||||
|
{showEmpty ? (
|
||||||
|
<Stack align="center" gap="xs" py={64}>
|
||||||
|
<Package size={28} className="text-slate-300" />
|
||||||
|
<Text c="edr-muted" size="sm">
|
||||||
|
No bookings yet — initiate one to get started.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
mt="md"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
onClick={() => setInitiateOpen(true)}
|
||||||
|
>
|
||||||
|
Initiate booking
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={status}
|
||||||
|
onRowClick={(row) =>
|
||||||
|
navigate(
|
||||||
|
`/shipping-line/bookings/${(row as ShippingLineBooking).id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(rows.length / pagination.pageSize),
|
||||||
|
),
|
||||||
|
totalCount: rows.length,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none rounded-none"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ShippingLineInitiateModal
|
||||||
|
opened={initiateOpen}
|
||||||
|
onClose={() => setInitiateOpen(false)}
|
||||||
|
onCreated={(booking) => {
|
||||||
|
setInitiateOpen(false);
|
||||||
|
// Straight into the new booking — documents are the next thing owed.
|
||||||
|
navigate(`/shipping-line/bookings/${booking.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShippingLineDocumentsModal
|
||||||
|
booking={docsBooking}
|
||||||
|
onClose={() => setDocsBooking(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, CheckCircle2, Clock, Upload } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document upload for a shipping-line booking.
|
||||||
|
*
|
||||||
|
* Deliberately the same surface as the customer clearance modal
|
||||||
|
* (`BookingActionModal`): the grid is built from the booking's clearance view,
|
||||||
|
* so every field shows its uploaded file, its review badge and the reviewer's
|
||||||
|
* note, and can be replaced in place — an approved document is locked, exactly
|
||||||
|
* as it is for customers.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineDocumentsModal({
|
||||||
|
booking,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
booking: ShippingLineBooking | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
// Files picked but not yet submitted, keyed by document field.
|
||||||
|
const [pending, setPending] = useState<Record<string, File>>({});
|
||||||
|
|
||||||
|
// Each booking gets a fresh sheet — otherwise files staged for one booking
|
||||||
|
// would still be attached when the modal reopens on another.
|
||||||
|
useEffect(() => {
|
||||||
|
setPending({});
|
||||||
|
}, [booking?.id]);
|
||||||
|
|
||||||
|
const clearanceQuery = useQuery({
|
||||||
|
queryKey: ["shipping-line-clearance", booking?.id],
|
||||||
|
queryFn: () => shippingLineBookingsService.getClearance(booking!.id),
|
||||||
|
enabled: booking !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
shippingLineBookingsService.uploadDocuments(booking!.id, pending),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPending({});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["shipping-line-bookings"],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["shipping-line-clearance", booking?.id],
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearance = clearanceQuery.data;
|
||||||
|
const status = clearance?.status ?? booking?.status ?? "";
|
||||||
|
|
||||||
|
// The fields this booking asks for. `uploadedBy: "gl"` rows are staff output
|
||||||
|
// documents, which the uploader never fills in.
|
||||||
|
const docs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "gl"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uploads are accepted while the booking is awaiting documents or already in
|
||||||
|
// review (fixing a queried one) — matching the server's own status gate.
|
||||||
|
const canUpload =
|
||||||
|
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||||
|
|
||||||
|
// Documents a reviewer sent back. These are what the shipping line has to
|
||||||
|
// act on, and the reason the modal leads with a red banner rather than the
|
||||||
|
// neutral "in review" one.
|
||||||
|
const queriedDocs = docs.filter((d) => d.reviewStatus === "QUERIED");
|
||||||
|
|
||||||
|
const missingRequired = docs.filter(
|
||||||
|
(d) => d.required && !d.file && !pending[d.fileKey],
|
||||||
|
);
|
||||||
|
const hasStaged = Object.keys(pending).length > 0;
|
||||||
|
|
||||||
|
// First submission must cover every required field; later rounds only need
|
||||||
|
// the specific documents being corrected.
|
||||||
|
const canSubmit = isInitialUpload
|
||||||
|
? hasStaged && missingRequired.length === 0
|
||||||
|
: hasStaged;
|
||||||
|
|
||||||
|
const stage = (fileKey: string, file: File | null) =>
|
||||||
|
setPending((p) => {
|
||||||
|
if (file) return { ...p, [fileKey]: file };
|
||||||
|
const next = { ...p };
|
||||||
|
delete next[fileKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={booking !== null}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
size="xl"
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Booking documents
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" ff="monospace">
|
||||||
|
{booking?.reference}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
styles={{ body: { paddingTop: 8 } }}
|
||||||
|
>
|
||||||
|
{clearanceQuery.isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : docs.length === 0 ? (
|
||||||
|
<Text fz="13px" c="dimmed" py="md">
|
||||||
|
No document requirements are configured yet. Staff set these up in
|
||||||
|
the backoffice under Settings → File settings.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{queriedDocs.length > 0 ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={18} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{queriedDocs.length === 1
|
||||||
|
? `"${queriedDocs[0].label}" was returned with a query. `
|
||||||
|
: `${queriedDocs.length} documents were returned with a query. `}
|
||||||
|
Read the note on each flagged document below and upload a
|
||||||
|
corrected file.
|
||||||
|
</Alert>
|
||||||
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
|
<Alert
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Our team is reviewing your documents. Only re-upload the
|
||||||
|
documents flagged with a query below — approved documents stay
|
||||||
|
as they are.
|
||||||
|
</Alert>
|
||||||
|
) : status === "CLEARANCE_READY" ? (
|
||||||
|
<Alert
|
||||||
|
color="teal"
|
||||||
|
radius="md"
|
||||||
|
icon={<CheckCircle2 size={18} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Your documents are approved.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={18} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Upload every required document (marked *) below to start the
|
||||||
|
review.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInitialUpload && missingRequired.length > 0 && (
|
||||||
|
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||||
|
<Text fz="12px" c="#9A5B00">
|
||||||
|
Still required:{" "}
|
||||||
|
{missingRequired.map((d) => d.label).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<ClearanceDocumentUploadCard
|
||||||
|
key={doc.fileKey}
|
||||||
|
label={doc.label}
|
||||||
|
required={doc.required}
|
||||||
|
reviewStatus={doc.reviewStatus ?? undefined}
|
||||||
|
note={doc.note}
|
||||||
|
uploadedFile={doc.file}
|
||||||
|
stagedFile={pending[doc.fileKey] ?? null}
|
||||||
|
canUpload={canUpload}
|
||||||
|
// An approved document is final — same rule as the customer
|
||||||
|
// flow, so it renders read-only with just a preview.
|
||||||
|
onStageFile={
|
||||||
|
canUpload && doc.reviewStatus !== "APPROVED"
|
||||||
|
? (file) => stage(doc.fileKey, file)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onPreview={view}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{uploadMutation.isError && (
|
||||||
|
<Alert color="red" mt="md" icon={<AlertCircle size={16} />}>
|
||||||
|
{(uploadMutation.error as Error)?.message ??
|
||||||
|
"Could not upload the documents."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{canUpload && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={() => uploadMutation.mutate()}
|
||||||
|
loading={uploadMutation.isPending}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
Submit documents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, MapPin, Plus } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
shippingLineBookingsService,
|
||||||
|
type ShippingLineBooking,
|
||||||
|
} from "@/services/shipping-line-bookings.service";
|
||||||
|
|
||||||
|
const FREIGHT_TYPES = [
|
||||||
|
{ value: "CONTAINER", label: "Container" },
|
||||||
|
{ value: "BULK", label: "Bulk" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a shipping-line booking.
|
||||||
|
*
|
||||||
|
* Origin and destination are picked separately (matching the customer form),
|
||||||
|
* but both lists are drawn from real routes, so the pair always resolves to a
|
||||||
|
* lane EDR runs. The request still sends that route's id, letting the server
|
||||||
|
* derive origin, destination and direction from one authoritative row.
|
||||||
|
*
|
||||||
|
* Only inbound (Djibouti to Ethiopia) lanes are offered — the API filters them
|
||||||
|
* and rejects anything else, so this is a fixed rule, not a UI convenience.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineInitiateModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (booking: ShippingLineBooking) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||||
|
const [destinationYardId, setDestinationYardId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||||
|
const [freightType, setFreightType] = useState<string>("CONTAINER");
|
||||||
|
|
||||||
|
// Fresh sheet each time it opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setOriginYardId(null);
|
||||||
|
setDestinationYardId(null);
|
||||||
|
setServiceTypeId(null);
|
||||||
|
setFreightType("CONTAINER");
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const referenceQuery = useQuery({
|
||||||
|
queryKey: ["shipping-line-reference-data"],
|
||||||
|
queryFn: shippingLineBookingsService.referenceData,
|
||||||
|
enabled: opened,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initiateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
shippingLineBookingsService.initiate({
|
||||||
|
routeId: selectedRoute!.id,
|
||||||
|
serviceTypeId: serviceTypeId ?? undefined,
|
||||||
|
freightType,
|
||||||
|
}),
|
||||||
|
onSuccess: (booking) => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["shipping-line-bookings"],
|
||||||
|
});
|
||||||
|
onCreated(booking);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const routes = useMemo(
|
||||||
|
() => referenceQuery.data?.routes ?? [],
|
||||||
|
[referenceQuery.data],
|
||||||
|
);
|
||||||
|
const serviceTypes = referenceQuery.data?.serviceTypes ?? [];
|
||||||
|
|
||||||
|
// Origin and destination are picked separately (as in the customer form), but
|
||||||
|
// the pair still has to be a route EDR actually runs — so each list is drawn
|
||||||
|
// from the routes, and the destination list narrows to what the chosen origin
|
||||||
|
// can reach. That keeps the familiar two-field shape without letting someone
|
||||||
|
// assemble a lane that does not exist.
|
||||||
|
const originOptions = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!seen.has(route.originYardId)) {
|
||||||
|
seen.set(route.originYardId, route.originLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen].map(([value, label]) => ({ value, label }));
|
||||||
|
}, [routes]);
|
||||||
|
|
||||||
|
const destinationOptions = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const route of routes) {
|
||||||
|
if (originYardId && route.originYardId !== originYardId) continue;
|
||||||
|
if (!seen.has(route.destinationYardId)) {
|
||||||
|
seen.set(route.destinationYardId, route.destinationLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen].map(([value, label]) => ({ value, label }));
|
||||||
|
}, [routes, originYardId]);
|
||||||
|
|
||||||
|
// The lane the two picks resolve to. Still sent as a routeId so the server
|
||||||
|
// keeps deriving origin/destination/direction from one authoritative row.
|
||||||
|
const selectedRoute = routes.find(
|
||||||
|
(r) =>
|
||||||
|
r.originYardId === originYardId &&
|
||||||
|
r.destinationYardId === destinationYardId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Changing the origin can invalidate an already-picked destination.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
destinationYardId &&
|
||||||
|
!destinationOptions.some((o) => o.value === destinationYardId)
|
||||||
|
) {
|
||||||
|
setDestinationYardId(null);
|
||||||
|
}
|
||||||
|
}, [destinationOptions, destinationYardId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Initiate booking
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
Pick the lane — you will upload documents next.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
>
|
||||||
|
{referenceQuery.isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : routes.length === 0 ? (
|
||||||
|
<Text fz="13px" c="dimmed" py="md">
|
||||||
|
No inbound routes are available for booking right now. Please contact
|
||||||
|
Operations.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group grow align="flex-start" gap="sm">
|
||||||
|
<Select
|
||||||
|
label="Origin yard (Djibouti)"
|
||||||
|
placeholder="Select origin..."
|
||||||
|
withAsterisk
|
||||||
|
searchable
|
||||||
|
data={originOptions}
|
||||||
|
value={originYardId}
|
||||||
|
onChange={setOriginYardId}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Destination yard (Ethiopia)"
|
||||||
|
placeholder="Select destination..."
|
||||||
|
withAsterisk
|
||||||
|
searchable
|
||||||
|
disabled={!originYardId}
|
||||||
|
data={destinationOptions}
|
||||||
|
value={destinationYardId}
|
||||||
|
onChange={setDestinationYardId}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Shipping lines only move inbound cargo, so the direction is fixed
|
||||||
|
rather than derived per pick — stated up front so the single
|
||||||
|
option in each list does not read as missing data. */}
|
||||||
|
<Alert
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
p="xs"
|
||||||
|
icon={<MapPin size={15} />}
|
||||||
|
>
|
||||||
|
<Text fz={12}>
|
||||||
|
Inbound only — cargo moves from Djibouti to Ethiopia (
|
||||||
|
<Text span fw={700}>
|
||||||
|
IMPORT
|
||||||
|
</Text>
|
||||||
|
).
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Freight type"
|
||||||
|
data={FREIGHT_TYPES}
|
||||||
|
value={freightType}
|
||||||
|
onChange={(v) => setFreightType(v ?? "CONTAINER")}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Only services that do NOT bundle customs are offered — the API
|
||||||
|
filters them and rejects the rest. If none are configured the
|
||||||
|
field says so rather than vanishing, which would read as a
|
||||||
|
missing form rather than a data gap. */}
|
||||||
|
{serviceTypes.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
label="Service"
|
||||||
|
placeholder="Select a service"
|
||||||
|
clearable
|
||||||
|
data={serviceTypes.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
|
value={serviceTypeId}
|
||||||
|
onChange={setServiceTypeId}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
label="Service"
|
||||||
|
placeholder="No non-customs service configured"
|
||||||
|
disabled
|
||||||
|
data={[]}
|
||||||
|
value={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initiateMutation.isError && (
|
||||||
|
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||||
|
{(initiateMutation.error as Error)?.message ??
|
||||||
|
"Could not initiate the booking."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
loading={initiateMutation.isPending}
|
||||||
|
disabled={!selectedRoute}
|
||||||
|
onClick={() => initiateMutation.mutate()}
|
||||||
|
>
|
||||||
|
Initiate booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the shipping line has to do about a booking's documents.
|
||||||
|
*
|
||||||
|
* Derived from the booking status AND the per-document review results, because
|
||||||
|
* the two disagree in the case that matters most: when a reviewer queries a
|
||||||
|
* document, that document's review status becomes QUERIED but the BOOKING stays
|
||||||
|
* on DOCUMENTS_UNDER_REVIEW. Keying the UI off status alone would keep showing
|
||||||
|
* "In review" while the shipping line is actually being asked to fix something.
|
||||||
|
*/
|
||||||
|
export type BookingDocState =
|
||||||
|
/** Nothing uploaded yet — the first submission is owed. */
|
||||||
|
| "AWAITING"
|
||||||
|
/** A reviewer sent something back; the shipping line must re-upload. */
|
||||||
|
| "ACTION_NEEDED"
|
||||||
|
/** Submitted and with Operations. */
|
||||||
|
| "IN_REVIEW"
|
||||||
|
/** Everything approved. */
|
||||||
|
| "APPROVED"
|
||||||
|
/** Documents do not apply at this status. */
|
||||||
|
| "NONE";
|
||||||
|
|
||||||
|
export function bookingDocState(
|
||||||
|
booking: Pick<ShippingLineBooking, "status" | "hasQueriedDocuments">,
|
||||||
|
): BookingDocState {
|
||||||
|
const status = booking.status as string;
|
||||||
|
|
||||||
|
// Checked before the status switch: a queried document outranks the booking's
|
||||||
|
// own DOCUMENTS_UNDER_REVIEW, which is exactly the case status alone misses.
|
||||||
|
if (booking.hasQueriedDocuments) return "ACTION_NEEDED";
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "AWAITING_DOCUMENTS":
|
||||||
|
return "AWAITING";
|
||||||
|
case "CHANGES_REQUESTED":
|
||||||
|
return "ACTION_NEEDED";
|
||||||
|
case "DOCUMENTS_UNDER_REVIEW":
|
||||||
|
return "IN_REVIEW";
|
||||||
|
case "CLEARANCE_READY":
|
||||||
|
return "APPROVED";
|
||||||
|
default:
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the document grid is worth opening at this state. */
|
||||||
|
export function hasDocuments(state: BookingDocState): boolean {
|
||||||
|
return state !== "NONE";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the shipping line owes an upload — drives the primary action label. */
|
||||||
|
export function needsUpload(state: BookingDocState): boolean {
|
||||||
|
return state === "AWAITING" || state === "ACTION_NEEDED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOC_STATE_LABEL: Record<BookingDocState, string> = {
|
||||||
|
AWAITING: "Documents needed",
|
||||||
|
ACTION_NEEDED: "Action needed",
|
||||||
|
IN_REVIEW: "In review",
|
||||||
|
APPROVED: "Approved",
|
||||||
|
NONE: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mantine colour for the state's badge/alert. */
|
||||||
|
export const DOC_STATE_COLOR: Record<BookingDocState, string> = {
|
||||||
|
AWAITING: "yellow",
|
||||||
|
ACTION_NEEDED: "red",
|
||||||
|
IN_REVIEW: "blue",
|
||||||
|
APPROVED: "teal",
|
||||||
|
NONE: "gray",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label for the button that opens the document grid. A queried document asks
|
||||||
|
* for a replacement, so it reads as an instruction rather than "View" — the
|
||||||
|
* shipping line must swap the file, not just look at it.
|
||||||
|
*/
|
||||||
|
export const DOC_STATE_ACTION_LABEL: Record<BookingDocState, string> = {
|
||||||
|
AWAITING: "Upload documents",
|
||||||
|
ACTION_NEEDED: "Change document",
|
||||||
|
IN_REVIEW: "View documents",
|
||||||
|
APPROVED: "View documents",
|
||||||
|
NONE: "View documents",
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
||||||
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
||||||
|
export { default as ShippingLineBookingDetailPage } from "./ShippingLineBookingDetailPage";
|
||||||
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
||||||
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
||||||
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { client } from "@/utils/api";
|
||||||
|
|
||||||
|
const BASE = "/api/shipping-line-bookings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `code` of the file-upload setting whose fields a shipping line fills in
|
||||||
|
* after initiating a booking. Configured in the backoffice file-settings editor
|
||||||
|
* (Other tab) and seeded by `file-upload-settings.seeder.ts`.
|
||||||
|
*/
|
||||||
|
export const SHIPPING_LINE_BOOKING_DOCUMENTS_CODE =
|
||||||
|
"shipping_line_booking_documents";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shipping-line booking as the portal sees it.
|
||||||
|
*
|
||||||
|
* `hasQueriedDocuments` is computed server-side: a reviewer querying a document
|
||||||
|
* leaves the booking on DOCUMENTS_UNDER_REVIEW, so the booking status alone
|
||||||
|
* cannot tell the UI that the shipping line has something to fix.
|
||||||
|
*/
|
||||||
|
export type ShippingLineBooking = Freight.IBooking & {
|
||||||
|
hasQueriedDocuments?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A bookable lane. Its direction is frozen server-side from the yards. */
|
||||||
|
export interface ShippingLineRouteOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
direction: string;
|
||||||
|
originYardId: string;
|
||||||
|
originLabel: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShippingLineReferenceData {
|
||||||
|
routes: ShippingLineRouteOption[];
|
||||||
|
serviceTypes: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route is the only lane input: it yields origin, destination and trade
|
||||||
|
* direction together, so they cannot contradict each other.
|
||||||
|
*/
|
||||||
|
export interface InitiateShippingLineBookingPayload {
|
||||||
|
routeId: string;
|
||||||
|
serviceTypeId?: string;
|
||||||
|
freightType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line bookings. Separate from `bookings.service.ts` (customers) — the
|
||||||
|
* endpoints differ, and shipping lines book without a contract.
|
||||||
|
*/
|
||||||
|
export const shippingLineBookingsService = {
|
||||||
|
/** Create a bare booking; it starts at AWAITING_DOCUMENTS. */
|
||||||
|
initiate: async (
|
||||||
|
payload: InitiateShippingLineBookingPayload,
|
||||||
|
): Promise<ShippingLineBooking> => {
|
||||||
|
const { data } = await client.post(`${BASE}/initiate`, payload);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Bookable routes + service types for the initiate form. */
|
||||||
|
referenceData: async (): Promise<ShippingLineReferenceData> => {
|
||||||
|
const { data } = await client.get(`${BASE}/reference-data`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
list: async (): Promise<ShippingLineBooking[]> => {
|
||||||
|
const { data } = await client.get(`${BASE}/my`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<ShippingLineBooking> => {
|
||||||
|
const { data } = await client.get(`${BASE}/${id}`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel one of the signed-in shipping line's own bookings. Only accepted
|
||||||
|
* before the booking is priced — the server enforces the same rule.
|
||||||
|
*/
|
||||||
|
cancel: async (id: string, reason?: string): Promise<ShippingLineBooking> => {
|
||||||
|
const { data } = await client.post(`${BASE}/${id}/cancel`, { reason });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document grid for a booking: every configured field with its uploaded
|
||||||
|
* file, review status and reviewer note. Same shared endpoint the customer
|
||||||
|
* clearance flow reads — it resolves the field set from the booking, which
|
||||||
|
* now maps shipping-line bookings to their own file-upload setting.
|
||||||
|
*/
|
||||||
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||||
|
const { data } = await client.get(`/api/bookings/${id}/clearance`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the booking's documents.
|
||||||
|
*
|
||||||
|
* Posts to the shared CLEARANCE documents endpoint, not `/documents`: the
|
||||||
|
* latter only accepts DRAFT bookings, and these start at AWAITING_DOCUMENTS.
|
||||||
|
* This is the same endpoint the customer clearance flow uses — it files each
|
||||||
|
* document for review and moves the booking to DOCUMENTS_UNDER_REVIEW.
|
||||||
|
*/
|
||||||
|
uploadDocuments: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | File[] | null>,
|
||||||
|
): Promise<ShippingLineBooking> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [key, fileOrFiles] of Object.entries(files)) {
|
||||||
|
if (!fileOrFiles) continue;
|
||||||
|
if (Array.isArray(fileOrFiles)) {
|
||||||
|
for (const f of fileOrFiles) formData.append(key, f);
|
||||||
|
} else {
|
||||||
|
formData.append(key, fileOrFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data } = await client.post(
|
||||||
|
`/api/bookings/${id}/clearance/documents`,
|
||||||
|
formData,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -188,6 +188,13 @@ export enum InvoiceSource {
|
|||||||
LastMile = "lastmile",
|
LastMile = "lastmile",
|
||||||
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
||||||
Clearance = "clearance",
|
Clearance = "clearance",
|
||||||
|
/**
|
||||||
|
* A batch of shipping-line credits billed together. Unlike every other
|
||||||
|
* source, `sourceId` is the shipping line's id rather than a single record's:
|
||||||
|
* the invoice covers many bookings, and the credits themselves carry the
|
||||||
|
* per-booking link.
|
||||||
|
*/
|
||||||
|
ShippingLineCredit = "shipping_line_credit",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SchedulingStatus {
|
export enum SchedulingStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user