mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
|||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||||
|
import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module";
|
||||||
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
||||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||||
@@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module";
|
|||||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||||
import { OverviewModule } from "./modules/overview/overview.module";
|
import { OverviewModule } from "./modules/overview/overview.module";
|
||||||
import { ReportsModule } from "./modules/reports/reports.module";
|
import { ReportsModule } from "./modules/reports/reports.module";
|
||||||
|
import { ExportsModule } from "./modules/exports/exports.module";
|
||||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
@@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ExchangeSettingsModule,
|
ExchangeSettingsModule,
|
||||||
|
OperationsReportingModule,
|
||||||
PaymentSettingsModule,
|
PaymentSettingsModule,
|
||||||
StampSettingsModule,
|
StampSettingsModule,
|
||||||
LogoSettingsModule,
|
LogoSettingsModule,
|
||||||
@@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
ExportsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
|
||||||
|
import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto';
|
||||||
|
import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route filters carry one id, `a,b`, or a repeated param, and the
|
||||||
|
* repositories then branch on `?.length` before emitting `IN (:...ids)`.
|
||||||
|
* Two things have to hold or that breaks at runtime, not compile time:
|
||||||
|
* the value must always arrive as an array (a bare string would make
|
||||||
|
* `.length` count characters), and an absent/blank param must arrive as
|
||||||
|
* `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`.
|
||||||
|
*/
|
||||||
|
// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a
|
||||||
|
// valid UUID and would fail `@IsUUID` for reasons that have nothing to do
|
||||||
|
// with the list transform under test.
|
||||||
|
const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab';
|
||||||
|
const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
|
||||||
|
const parse = <T>(cls: new () => T, query: Record<string, unknown>): T =>
|
||||||
|
plainToInstance(cls, query);
|
||||||
|
|
||||||
|
describe('route id-list query params', () => {
|
||||||
|
it('accepts a single id, still as an array', () => {
|
||||||
|
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||||
|
expect(dto.originYardId).toEqual([A]);
|
||||||
|
expect(validateSync(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a comma-separated list', () => {
|
||||||
|
const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` });
|
||||||
|
expect(dto.originYardId).toEqual([A, B]);
|
||||||
|
expect(validateSync(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the repeated-param form', () => {
|
||||||
|
const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] });
|
||||||
|
expect(dto.destinationStationId).toEqual([A, B]);
|
||||||
|
expect(validateSync(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => {
|
||||||
|
expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the two ends independent — one side set, the other absent', () => {
|
||||||
|
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||||
|
expect(dto.originYardId).toEqual([A]);
|
||||||
|
expect(dto.destinationYardId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects a non-uuid inside the list', () => {
|
||||||
|
const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` });
|
||||||
|
expect(validateSync(dto)).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query param that carries one id, a comma-separated list (`a,b,c`), or the
|
||||||
|
* same key repeated — and always lands on the DTO as a `string[]`.
|
||||||
|
*
|
||||||
|
* Two details matter:
|
||||||
|
*
|
||||||
|
* - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional`
|
||||||
|
* then short-circuits, and — more importantly — a repository that does
|
||||||
|
* `if (ids?.length)` can never be handed an empty array, which TypeORM turns
|
||||||
|
* into the syntax error `IN ()`.
|
||||||
|
* - It is backwards compatible with the single-value form these params used to
|
||||||
|
* take, so existing deep links and saved views keep working unchanged.
|
||||||
|
*
|
||||||
|
* Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each`
|
||||||
|
* validator) — this only reshapes the value, it does not validate it.
|
||||||
|
*/
|
||||||
|
export const IdListParam = () =>
|
||||||
|
Transform(({ value }: { value: unknown }) => {
|
||||||
|
const raw = Array.isArray(value) ? value : [value];
|
||||||
|
const ids = raw
|
||||||
|
.flatMap((entry) =>
|
||||||
|
entry === undefined || entry === null ? [] : String(entry).split(','),
|
||||||
|
)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
});
|
||||||
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal file
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
|
||||||
|
import { PaginationQueryDto } from './pagination-query.dto';
|
||||||
|
import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto';
|
||||||
|
import { normalizePagination } from '../utils/pagination.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page-size ceiling is stated in three places that must agree: `@Max` on
|
||||||
|
* PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which
|
||||||
|
* doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy
|
||||||
|
* lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table
|
||||||
|
* footer, which is what actually asks for the number. Drift between any of
|
||||||
|
* them shows up as a 400 on the largest rows-per-page option, so pin them.
|
||||||
|
*/
|
||||||
|
const errorsFor = (cls: any, pageSize: unknown) =>
|
||||||
|
validateSync(plainToInstance(cls, { pageSize }), { whitelist: false });
|
||||||
|
|
||||||
|
describe('page size ceiling', () => {
|
||||||
|
it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => {
|
||||||
|
expect(errorsFor(cls, 500)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => {
|
||||||
|
expect(errorsFor(cls, 501)).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not truncate 500 in the service-side clamp', () => {
|
||||||
|
expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500);
|
||||||
|
expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,12 +19,18 @@ export class PaginationQueryDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
/**
|
||||||
|
* Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts`
|
||||||
|
* and the backoffice table footer's largest option. The three have to agree:
|
||||||
|
* a lower value here turns the footer's top preset into a 400, a higher one
|
||||||
|
* lets a request through that the util then silently truncates.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(100)
|
@Max(500)
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export interface NormalizedPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
const MAX_PAGE_SIZE = 100;
|
/**
|
||||||
|
* Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with
|
||||||
|
* `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects,
|
||||||
|
* this clamps, and the footer is what actually asks for the number.
|
||||||
|
*/
|
||||||
|
const MAX_PAGE_SIZE = 500;
|
||||||
|
|
||||||
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
||||||
export function normalizePagination(
|
export function normalizePagination(
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
|||||||
DEMURRAGE: 'Demurrage / wagon detention',
|
DEMURRAGE: 'Demurrage / wagon detention',
|
||||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||||
|
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
|
||||||
FUEL: 'Fuel surcharge',
|
FUEL: 'Fuel surcharge',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
|
||||||
|
*
|
||||||
|
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
|
||||||
|
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
|
||||||
|
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
|
||||||
|
* batch (webhook never arrived) can be found and reconciled by conversation id.
|
||||||
|
*/
|
||||||
|
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
|
||||||
|
name = "EimsBulkRegistration3580000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
DROP COLUMN IF EXISTS in_flight_conversation_id
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_bulk_conversation_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference data for the operations reporting suite (turnaround, delay,
|
||||||
|
* trainset, TEU, cargo volume).
|
||||||
|
*
|
||||||
|
* Two new tables and two new columns:
|
||||||
|
*
|
||||||
|
* - `operations_standards` — single-row settings table, same shape as
|
||||||
|
* `logo_settings` / `exchange_settings`. Holds the railway's standard times
|
||||||
|
* and charged-tonnage factors. Editable in the backoffice because the
|
||||||
|
* business calls the corridor standard "flexible".
|
||||||
|
* - `operations_targets` — the planned side of every "Plan / Operated /
|
||||||
|
* Implement Rate" table in the spec. One row per period × metric ×
|
||||||
|
* dimension value.
|
||||||
|
* - `yard_distances.standard_hours` — the per-corridor standard transit time
|
||||||
|
* (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to
|
||||||
|
* `operations_standards.default_leg_standard_hours`.
|
||||||
|
* - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this
|
||||||
|
* cargo (37 for vehicles, 22 for sand). Null falls back to
|
||||||
|
* `operations_standards.default_full_trainset_wagons`.
|
||||||
|
*
|
||||||
|
* The seed row is inserted only when the table is empty, so re-running this
|
||||||
|
* never overwrites values an operator has since edited.
|
||||||
|
*/
|
||||||
|
export class OperationsReporting3580000000000 implements MigrationInterface {
|
||||||
|
name = "OperationsReporting3580000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.operations_standards (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10,
|
||||||
|
station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13,
|
||||||
|
cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65,
|
||||||
|
cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88,
|
||||||
|
cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96,
|
||||||
|
cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96,
|
||||||
|
default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21,
|
||||||
|
delay_tolerance_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20,
|
||||||
|
charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40,
|
||||||
|
charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24,
|
||||||
|
charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88,
|
||||||
|
charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70,
|
||||||
|
charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38,
|
||||||
|
default_full_trainset_wagons integer NOT NULL DEFAULT 50,
|
||||||
|
updated_by_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Column defaults carry every value — the seed only needs the row to exist.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.operations_standards (id)
|
||||||
|
SELECT gen_random_uuid()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.operations_targets (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
period_type varchar(10) NOT NULL,
|
||||||
|
period_start date NOT NULL,
|
||||||
|
metric varchar(20) NOT NULL,
|
||||||
|
dimension varchar(20) NOT NULL,
|
||||||
|
dimension_key varchar(60) NOT NULL,
|
||||||
|
planned_value numeric(14,3) NOT NULL,
|
||||||
|
note text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Partial unique index rather than a table constraint, so a soft-deleted
|
||||||
|
// target can be re-created — same choice as yard_distances.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||||
|
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The reports look targets up by period and metric, never by id.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup
|
||||||
|
ON freight.operations_targets (metric, period_type, period_start)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yard_distances
|
||||||
|
ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD COLUMN IF NOT EXISTS full_trainset_wagons integer;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
|
||||||
|
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
|
||||||
|
* currency and its own payable invoice.
|
||||||
|
*/
|
||||||
|
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingClearanceCharge3590000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"booking_id" uuid NOT NULL,
|
||||||
|
"type" character varying(20) NOT NULL,
|
||||||
|
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
|
||||||
|
"file_record_id" uuid,
|
||||||
|
"amount" numeric(14,2),
|
||||||
|
"currency" character varying(8),
|
||||||
|
"invoice_id" uuid,
|
||||||
|
"uploaded_by_staff_id" uuid,
|
||||||
|
"uploaded_at" timestamptz,
|
||||||
|
"billed_by_staff_id" uuid,
|
||||||
|
"billed_at" timestamptz,
|
||||||
|
"paid_at" timestamptz,
|
||||||
|
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
|
||||||
|
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
|
||||||
|
ON "freight"."booking_clearance_charge" ("booking_id", "type")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A station's plan is per station AND per cargo type, not per station.
|
||||||
|
*
|
||||||
|
* The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and
|
||||||
|
* "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station,
|
||||||
|
* which the single `dimension_key` column cannot express: a station-keyed target
|
||||||
|
* would apply the whole station's plan to each of its cargo types.
|
||||||
|
*
|
||||||
|
* `cargo_category` is nullable, so `cargo_category` and `container_class`
|
||||||
|
* targets are unaffected — they leave it null and stay keyed on
|
||||||
|
* `dimension_key` alone. The uniqueness index moves to include it, since
|
||||||
|
* (station, category) is now the slot.
|
||||||
|
*/
|
||||||
|
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
|
||||||
|
name = "OperationsTargetCargoCategory3590000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.operations_targets
|
||||||
|
ADD COLUMN IF NOT EXISTS cargo_category varchar(60);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
|
||||||
|
|
||||||
|
// COALESCE rather than a plain column list: a partial unique index treats
|
||||||
|
// NULLs as distinct, which would let the same category target be entered
|
||||||
|
// twice over.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||||
|
ON freight.operations_targets (
|
||||||
|
period_type, period_start, metric, dimension, dimension_key,
|
||||||
|
COALESCE(cargo_category, '')
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||||
|
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/** Per-booking clearance action history — drives the History tab. */
|
||||||
|
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingClearanceEvent3600000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"booking_id" uuid NOT NULL,
|
||||||
|
"action" character varying(64) NOT NULL,
|
||||||
|
"label" character varying(500) NOT NULL,
|
||||||
|
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
|
||||||
|
"actor_id" uuid,
|
||||||
|
"actor_name" character varying(150),
|
||||||
|
"metadata" jsonb,
|
||||||
|
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
|
||||||
|
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
|
||||||
|
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia
|
||||||
|
* may raise several MISCELLANEOUS charges, and either level may be created
|
||||||
|
* first. Port charges stay unique per booking (one port bill per shipment),
|
||||||
|
* enforced by a partial index instead of the old blanket (booking_id, type)
|
||||||
|
* uniqueness that also capped miscellaneous at one.
|
||||||
|
*/
|
||||||
|
export class MultipleMiscClearanceCharges3610000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'MultipleMiscClearanceCharges3610000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port"
|
||||||
|
ON "freight"."booking_clearance_charge" ("booking_id")
|
||||||
|
WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking"
|
||||||
|
ON "freight"."booking_clearance_charge" ("booking_id")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// No-op on the uniqueness: restoring the blanket (booking_id, type) index
|
||||||
|
// would fail on any booking that has since raised a second miscellaneous
|
||||||
|
// charge, which is exactly what this migration set out to allow.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */
|
||||||
|
export class AdditionalCharge3620000000000 implements MigrationInterface {
|
||||||
|
name = 'AdditionalCharge3620000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."additional_charge" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"booking_id" uuid NOT NULL,
|
||||||
|
"reason" text NOT NULL,
|
||||||
|
"status" character varying(20) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"amount" numeric(14,2) NOT NULL,
|
||||||
|
"currency" character varying(8) NOT NULL,
|
||||||
|
"file_record_id" uuid,
|
||||||
|
"invoice_id" uuid,
|
||||||
|
"payment_reference" character varying(64),
|
||||||
|
"created_by_staff_id" uuid,
|
||||||
|
"sent_by_staff_id" uuid,
|
||||||
|
"sent_at" timestamptz,
|
||||||
|
"paid_at" timestamptz,
|
||||||
|
"cancelled_by_staff_id" uuid,
|
||||||
|
"cancelled_at" timestamptz,
|
||||||
|
"cancel_reason" text,
|
||||||
|
CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id")
|
||||||
|
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking"
|
||||||
|
ON "freight"."additional_charge" ("booking_id")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-schedule wagon yard plan — where THIS departure expects each consist
|
||||||
|
* wagon to board, independent of where the wagon physically stands today.
|
||||||
|
*
|
||||||
|
* `wagons.current_yard_id` is one physical fact shared by every schedule of a
|
||||||
|
* built train, so a train standing in Mojo could not be sold from Dire for a
|
||||||
|
* departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }`
|
||||||
|
* on the schedule: a wagon missing from the map boards from its physical yard.
|
||||||
|
* Booking capacity, fleet availability and wagon pinning all read the plan;
|
||||||
|
* dispatch refuses to leave until the plan and the physical yards agree.
|
||||||
|
*/
|
||||||
|
export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface {
|
||||||
|
name = 'SchedulePlannedWagonYards3620000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer now approves a clearance charge before it becomes an invoice:
|
||||||
|
* GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge
|
||||||
|
* locked) or REJECTs with a note (GL revises and re-sends). Charges that were
|
||||||
|
* already sent as invoices under the old flow are carried over as ACCEPTED so
|
||||||
|
* their invoices stay payable.
|
||||||
|
*/
|
||||||
|
export class ClearanceChargeCustomerDecision3630000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'ClearanceChargeCustomerDecision3630000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "freight"."booking_clearance_charge"
|
||||||
|
ADD COLUMN IF NOT EXISTS "description" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "customer_note" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "freight"."booking_clearance_charge"
|
||||||
|
SET "status" = 'ACCEPTED'
|
||||||
|
WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "freight"."booking_clearance_charge"
|
||||||
|
SET "status" = 'SENT'
|
||||||
|
WHERE "status" = 'ACCEPTED'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "freight"."booking_clearance_charge"
|
||||||
|
SET "status" = 'BILLED'
|
||||||
|
WHERE "status" = 'REJECTED'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "freight"."booking_clearance_charge"
|
||||||
|
DROP COLUMN IF EXISTS "description",
|
||||||
|
DROP COLUMN IF EXISTS "customer_note",
|
||||||
|
DROP COLUMN IF EXISTS "customer_decided_at",
|
||||||
|
DROP COLUMN IF EXISTS "customer_decided_by"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ethiopian-side-only customs clearance:
|
||||||
|
*
|
||||||
|
* - service_types.includes_ethiopian_customs_only marks a customs service that
|
||||||
|
* EDR clears on the Ethiopian side only. Same clearance flow; only the fee
|
||||||
|
* differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of
|
||||||
|
* CUSTOMS_CLEARANCE.
|
||||||
|
* - rates.trigger widens to 30 chars to fit the new trigger value.
|
||||||
|
* - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying
|
||||||
|
* branch: it is priced per origin → destination leg like customs clearance.
|
||||||
|
*/
|
||||||
|
export class EthiopianCustomsClearance3640000000000 implements MigrationInterface {
|
||||||
|
name = 'EthiopianCustomsClearance3640000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.service_types
|
||||||
|
ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
// Rows on the new trigger would not fit varchar(20) — drop them first.
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||||
|
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
||||||
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common";
|
|||||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
|
||||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||||
// payers straight off the table.
|
// payers straight off the table.
|
||||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||||
@@ -46,6 +47,29 @@ export interface PayInvoiceOptions {
|
|||||||
failureUrl?: string;
|
failureUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an invoice's `sourceId` actually points at, resolved for display.
|
||||||
|
*
|
||||||
|
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
|
||||||
|
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
|
||||||
|
* source except a shipping-line credit hangs off a booking — directly
|
||||||
|
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
|
||||||
|
* so the booking reference is the one label that identifies almost any row.
|
||||||
|
*/
|
||||||
|
export interface InvoiceSourceRef {
|
||||||
|
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
|
||||||
|
grnNumber: string | null;
|
||||||
|
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
|
||||||
|
shippingLineName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
|
||||||
|
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
|
||||||
|
|
||||||
/** Booking context attached to a finance offline-USD invoice row. */
|
/** Booking context attached to a finance offline-USD invoice row. */
|
||||||
export interface OfflineUsdBookingInfo {
|
export interface OfflineUsdBookingInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -236,8 +260,32 @@ export class BillingService {
|
|||||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
}
|
}
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
|
// Searches what the row actually shows: its number, who it bills, and
|
||||||
|
// the source record behind it (booking reference, GRN, shipping line).
|
||||||
|
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
||||||
|
// Requires the `company` alias — every caller of this joins it.
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
`(invoice.invoiceNumber ILIKE :search
|
||||||
|
OR invoice.sourceId ILIKE :search
|
||||||
|
OR company.name ILIKE :search
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
||||||
|
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
||||||
|
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
||||||
|
WHERE b.reference ILIKE :search
|
||||||
|
AND (b.id::text = invoice.source_id
|
||||||
|
OR wi.id::text = invoice.source_id
|
||||||
|
OR fm.id::text = invoice.source_id
|
||||||
|
OR lm.id::text = invoice.source_id))
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM freight.warehouse_inventory wi2
|
||||||
|
WHERE wi2.id::text = invoice.source_id
|
||||||
|
AND wi2.grn_number ILIKE :search)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM freight.shipping_line_companies slc
|
||||||
|
WHERE slc.id::text = invoice.source_id
|
||||||
|
AND slc.name ILIKE :search))`,
|
||||||
{ search: `%${filter.search}%` },
|
{ search: `%${filter.search}%` },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -261,7 +309,7 @@ export class BillingService {
|
|||||||
/** Per-user trade-direction scope, applied via the source booking. */
|
/** Per-user trade-direction scope, applied via the source booking. */
|
||||||
tradeDirections?: string[];
|
tradeDirections?: string[];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{ items: Invoice[]; total: number }> {
|
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
||||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
const pageSize =
|
const pageSize =
|
||||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
@@ -277,7 +325,92 @@ export class BillingService {
|
|||||||
this.applyInvoiceFilters(qb, filter);
|
this.applyInvoiceFilters(qb, filter);
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [items, total] = await qb.getManyAndCount();
|
||||||
return { items: await this.attachShippingLineCompanies(items), total };
|
const withLines = await this.attachShippingLineCompanies(items);
|
||||||
|
return { items: await this.attachSourceRefs(withLines), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve each row's `sourceId` to the record it points at, in one query for
|
||||||
|
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
|
||||||
|
* relation to eager-load, and which table it addresses depends on `source` —
|
||||||
|
* so this walks every candidate table at once and lands on the booking
|
||||||
|
* through whichever one matched.
|
||||||
|
*
|
||||||
|
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
|
||||||
|
* the shape guard before every cast — an unguarded `::uuid` throws on those.
|
||||||
|
*/
|
||||||
|
private async attachSourceRefs<T extends Invoice>(
|
||||||
|
invoices: T[],
|
||||||
|
): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> {
|
||||||
|
const sourceIds = [
|
||||||
|
...new Set(invoices.map((i) => i.sourceId).filter(Boolean)),
|
||||||
|
];
|
||||||
|
if (!sourceIds.length) {
|
||||||
|
return invoices.map((invoice) => ({ ...invoice, sourceRef: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: {
|
||||||
|
sourceId: string;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
grnNumber: string | null;
|
||||||
|
shippingLineName: string | null;
|
||||||
|
}[] = await this.dataSource.query(
|
||||||
|
`SELECT s.source_id AS "sourceId",
|
||||||
|
b.id::text AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
b.trade_direction AS "tradeDirection",
|
||||||
|
wi.grn_number AS "grnNumber",
|
||||||
|
slc.name AS "shippingLineName"
|
||||||
|
FROM unnest($1::text[]) AS s(source_id)
|
||||||
|
LEFT JOIN freight.warehouse_inventory wi
|
||||||
|
ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN s.source_id::uuid END)
|
||||||
|
AND wi.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.first_mile fm
|
||||||
|
ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN s.source_id::uuid END)
|
||||||
|
AND fm.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.last_mile lm
|
||||||
|
ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN s.source_id::uuid END)
|
||||||
|
AND lm.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.bookings b
|
||||||
|
ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id,
|
||||||
|
CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN s.source_id::uuid END)
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.shipping_line_companies slc
|
||||||
|
ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||||
|
THEN s.source_id::uuid END)
|
||||||
|
AND slc.deleted_at IS NULL`,
|
||||||
|
[sourceIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bySourceId = new Map(rows.map((r) => [r.sourceId, r]));
|
||||||
|
return invoices.map((invoice) => {
|
||||||
|
const row = bySourceId.get(invoice.sourceId);
|
||||||
|
const sourceRef: InvoiceSourceRef | null = row
|
||||||
|
? {
|
||||||
|
bookingId: row.bookingId,
|
||||||
|
bookingReference: row.bookingReference,
|
||||||
|
tradeDirection: row.tradeDirection,
|
||||||
|
grnNumber: row.grnNumber,
|
||||||
|
shippingLineName: row.shippingLineName,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
// Nothing resolved (an EIMS self-test row, a deleted record) → null,
|
||||||
|
// and the UI falls back to the plain source label.
|
||||||
|
const resolved =
|
||||||
|
sourceRef &&
|
||||||
|
(sourceRef.bookingId ||
|
||||||
|
sourceRef.grnNumber ||
|
||||||
|
sourceRef.shippingLineName)
|
||||||
|
? sourceRef
|
||||||
|
: null;
|
||||||
|
return { ...invoice, sourceRef: resolved };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,6 +469,9 @@ export class BillingService {
|
|||||||
const qb = this.dataSource
|
const qb = this.dataSource
|
||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.createQueryBuilder("invoice")
|
.createQueryBuilder("invoice")
|
||||||
|
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
||||||
|
// so the alias has to exist even though the summary only sums money.
|
||||||
|
.leftJoin("invoice.company", "company")
|
||||||
.select("invoice.currency", "currency")
|
.select("invoice.currency", "currency")
|
||||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||||
.groupBy("invoice.currency");
|
.groupBy("invoice.currency");
|
||||||
@@ -1823,6 +1959,14 @@ export class BillingService {
|
|||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
||||||
}
|
}
|
||||||
|
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||||
|
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||||
|
// can carry many of these at once.
|
||||||
|
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(AdditionalCharge)
|
||||||
|
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||||
|
}
|
||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept for local demos only.
|
// billing must not simulate it. Kept for local demos only.
|
||||||
|
|||||||
@@ -203,4 +203,12 @@ export class Invoice extends BaseEntity {
|
|||||||
@ManyToOne(() => Invoice)
|
@ManyToOne(() => Invoice)
|
||||||
@JoinColumn({ name: "related_invoice_id" })
|
@JoinColumn({ name: "related_invoice_id" })
|
||||||
relatedInvoice?: Invoice | null;
|
relatedInvoice?: Invoice | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own
|
||||||
|
* conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and
|
||||||
|
* reconciled. Null for every invoice filed through single `/v1/register`.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true })
|
||||||
|
eimsBulkConversationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { adHocLabel } from './clearance.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's typed document name travels to the API inside the multipart
|
||||||
|
* field code (`custom_<slug>_<n>`) — the only channel a part has — and comes
|
||||||
|
* back out here for GL's review grid. Mirror of `adHocSlug` in the portal's
|
||||||
|
* useClearanceFlow.
|
||||||
|
*/
|
||||||
|
const adHocSlug = (name: string) =>
|
||||||
|
name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60);
|
||||||
|
|
||||||
|
const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`);
|
||||||
|
|
||||||
|
describe('adHocLabel', () => {
|
||||||
|
it('recovers the name the customer typed', () => {
|
||||||
|
expect(roundTrip('Special permit')).toBe('Special permit');
|
||||||
|
expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate');
|
||||||
|
expect(roundTrip('bank slip #2')).toBe('Bank slip 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no name to show, so callers use the filename', () => {
|
||||||
|
expect(roundTrip('')).toBeNull();
|
||||||
|
// Legacy uploads keyed `custom_<timestamp>_<n>` carry no name — without the
|
||||||
|
// digits guard this would surface "1755780000000" as the document label.
|
||||||
|
expect(adHocLabel('custom_1755780000000_0')).toBeNull();
|
||||||
|
expect(adHocLabel('commercial_invoice')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdditionalChargeRepository extends BaseRepository<AdditionalCharge> {
|
||||||
|
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||||
|
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||||
|
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||||
|
|
||||||
|
const FILE_RESOURCE = 'additional_charges';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ad-hoc extra charges finance raises against a booking, independent of
|
||||||
|
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
|
||||||
|
* row per booking). Any number per booking, free-text reason. DRAFT until
|
||||||
|
* sent; sending issues the payable invoice and notifies the customer
|
||||||
|
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
|
||||||
|
* same event-driven pattern as every other invoice source.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdditionalChargeService {
|
||||||
|
private readonly logger = new Logger(AdditionalChargeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly repository: AdditionalChargeRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
|
||||||
|
const charge = await this.repository.findById(chargeId);
|
||||||
|
if (!charge || charge.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Additional charge not found');
|
||||||
|
}
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
|
||||||
|
const rows = await this.repository.findAll({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
return this.toDtoList(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
bookingId: string,
|
||||||
|
dto: CreateAdditionalChargeDto,
|
||||||
|
staffId: string,
|
||||||
|
file?: Express.Multer.File,
|
||||||
|
): Promise<Freight.AdditionalCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
const shouldSend = dto.action === 'send';
|
||||||
|
|
||||||
|
const chargeId = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(AdditionalCharge);
|
||||||
|
let saved = await repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId,
|
||||||
|
reason: dto.reason.trim(),
|
||||||
|
amount: dto.amount.toFixed(2),
|
||||||
|
currency: dto.currency.trim().toUpperCase(),
|
||||||
|
status: 'DRAFT',
|
||||||
|
createdByStaffId: staffId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const record = await this.filesService.upload({
|
||||||
|
resourceId: saved.id,
|
||||||
|
resource: FILE_RESOURCE,
|
||||||
|
code: FILE_RESOURCE,
|
||||||
|
file,
|
||||||
|
uploadedByUserId: staffId,
|
||||||
|
});
|
||||||
|
await repo.update(saved.id, { fileRecordId: record.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSend) {
|
||||||
|
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
|
||||||
|
}
|
||||||
|
return saved.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldSend) await this.notifyCustomerSent(chargeId);
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
|
||||||
|
const charge = await this.findOwned(bookingId, chargeId);
|
||||||
|
if (charge.status !== 'DRAFT') {
|
||||||
|
throw new ConflictException('Only a draft charge can be sent.');
|
||||||
|
}
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
|
||||||
|
await this.dataSource.transaction((manager) =>
|
||||||
|
this.issueInvoice(manager, charge.id, booking, staffId),
|
||||||
|
);
|
||||||
|
await this.notifyCustomerSent(charge.id);
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
|
||||||
|
private async issueInvoice(
|
||||||
|
manager: EntityManager,
|
||||||
|
chargeId: string,
|
||||||
|
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
|
||||||
|
staffId: string,
|
||||||
|
): Promise<AdditionalCharge> {
|
||||||
|
const repo = manager.getRepository(AdditionalCharge);
|
||||||
|
const charge = await repo.findOneByOrFail({ id: chargeId });
|
||||||
|
|
||||||
|
const invoice = await this.billing.generateInvoice(
|
||||||
|
{
|
||||||
|
source: Freight.InvoiceSource.AdditionalCharge,
|
||||||
|
sourceId: charge.id,
|
||||||
|
type: 'ADDITIONAL_CHARGE',
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency: charge.currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: 'ADDITIONAL_CHARGE',
|
||||||
|
description: `${charge.reason} — ${booking.reference ?? booking.id}`,
|
||||||
|
amount: Number(charge.amount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repo.update(charge.id, {
|
||||||
|
status: 'SENT',
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
sentByStaffId: staffId,
|
||||||
|
sentAt: new Date(),
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
|
||||||
|
);
|
||||||
|
return repo.findOneByOrFail({ id: charge.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyCustomerSent(chargeId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const charge = await this.repository.findById(chargeId);
|
||||||
|
if (!charge) return;
|
||||||
|
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||||
|
if (!booking.companyId) return;
|
||||||
|
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
title: 'New charge on your booking',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${charge.bookingId}`,
|
||||||
|
data: {
|
||||||
|
bookingId: charge.bookingId,
|
||||||
|
chargeId: charge.id,
|
||||||
|
amount: Number(charge.amount),
|
||||||
|
currency: charge.currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
staffId: string,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<Freight.AdditionalCharge[]> {
|
||||||
|
const charge = await this.findOwned(bookingId, chargeId);
|
||||||
|
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
|
||||||
|
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
|
||||||
|
}
|
||||||
|
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||||
|
await this.billing.cancelInvoice(charge.invoiceId);
|
||||||
|
}
|
||||||
|
await this.repository.update(charge.id, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
cancelledByStaffId: staffId,
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
cancelReason: reason ?? null,
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||||
|
@OnEvent('additional_charge.invoice.paid')
|
||||||
|
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
const charge = await this.repository.findById(payload.sourceId);
|
||||||
|
if (!charge || charge.status === 'PAID') return;
|
||||||
|
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||||
|
if (!booking.companyId) return;
|
||||||
|
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
|
title: 'Charge payment received',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${charge.bookingId}`,
|
||||||
|
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||||
|
});
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
|
title: 'Additional charge paid',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${charge.bookingId}`,
|
||||||
|
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
|
||||||
|
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
|
||||||
|
rows.map((r) => r.id),
|
||||||
|
FILE_RESOURCE,
|
||||||
|
);
|
||||||
|
const names = await this.bookingsRepository.resolveStaffNames(
|
||||||
|
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
|
||||||
|
const invoices = invoiceIds.length
|
||||||
|
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
|
||||||
|
: [];
|
||||||
|
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const file = filesByCharge.get(r.id)?.[0];
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
bookingId: r.bookingId,
|
||||||
|
reason: r.reason,
|
||||||
|
status: r.status,
|
||||||
|
amount: Number(r.amount),
|
||||||
|
currency: r.currency,
|
||||||
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
|
invoiceId: r.invoiceId ?? null,
|
||||||
|
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
|
||||||
|
paymentReference: r.paymentReference ?? null,
|
||||||
|
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
|
||||||
|
sentAt: r.sentAt?.toISOString() ?? null,
|
||||||
|
paidAt: r.paidAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: r.cancelledAt?.toISOString() ?? null,
|
||||||
|
cancelReason: r.cancelReason ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import {
|
||||||
|
BookingClearanceCharge,
|
||||||
|
ClearanceChargeStatus,
|
||||||
|
ClearanceChargeType,
|
||||||
|
} from './entities/booking-clearance-charge.entity';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
|
|
||||||
|
/** File-record codes the charge documents are stored under on the booking. */
|
||||||
|
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
||||||
|
PORT_CHARGES: 'clearance_charge_port',
|
||||||
|
MISCELLANEOUS: 'clearance_charge_misc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||||
|
PORT_CHARGES: 'Port charges',
|
||||||
|
MISCELLANEOUS: 'Miscellaneous charges',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
|
||||||
|
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
|
||||||
|
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||||
|
|
||||||
|
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
|
||||||
|
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
|
||||||
|
status !== 'ACCEPTED' && status !== 'PAID';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charges billed to the customer: one port charge
|
||||||
|
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
|
||||||
|
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
|
||||||
|
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
|
||||||
|
* the payable invoice and locks the charge. ETB invoices are paid through the
|
||||||
|
* portal gateway, other currencies through Finance's manual settlement
|
||||||
|
* worklist — both settle via `clearance_charge.invoice.paid`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingClearanceChargeService {
|
||||||
|
private readonly logger = new Logger(BookingClearanceChargeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private repo() {
|
||||||
|
return this.dataSource.getRepository(BookingClearanceCharge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charges are a post-finalization step: block while the customer's clearance
|
||||||
|
* documents are still being collected/reviewed.
|
||||||
|
*/
|
||||||
|
private assertClearanceFinalized(booking: Booking): void {
|
||||||
|
const inReview =
|
||||||
|
booking.status === 'AWAITING_DOCUMENTS' ||
|
||||||
|
booking.status === 'DOCUMENTS_UNDER_REVIEW';
|
||||||
|
if (inReview && !booking.preClearanceFinalizedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Clearance charges open after document clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charges = await this.repo().find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
if (charges.length === 0) return [];
|
||||||
|
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const fileById = new Map(files.map((f) => [f.id, f]));
|
||||||
|
const names = await this.bookingsRepository.resolveStaffNames(
|
||||||
|
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
|
||||||
|
);
|
||||||
|
const invoiceIds = charges
|
||||||
|
.map((c) => c.invoiceId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
const invoices = invoiceIds.length
|
||||||
|
? await this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.find({ where: invoiceIds.map((id) => ({ id })) })
|
||||||
|
: [];
|
||||||
|
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
return charges.map((c) => {
|
||||||
|
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
bookingId: c.bookingId,
|
||||||
|
type: c.type,
|
||||||
|
status: c.status,
|
||||||
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
|
amount: c.amount != null ? Number(c.amount) : null,
|
||||||
|
currency: c.currency ?? null,
|
||||||
|
description: c.description ?? null,
|
||||||
|
customerNote: c.customerNote ?? null,
|
||||||
|
customerDecidedAt: c.customerDecidedAt
|
||||||
|
? c.customerDecidedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
invoiceId: c.invoiceId ?? null,
|
||||||
|
invoiceNumber: c.invoiceId
|
||||||
|
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||||
|
: null,
|
||||||
|
uploadedByName: c.uploadedByStaffId
|
||||||
|
? (names.get(c.uploadedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
|
||||||
|
billedByName: c.billedByStaffId
|
||||||
|
? (names.get(c.billedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
|
||||||
|
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer's view: only charges GL has sent them. */
|
||||||
|
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
return (await this.list(bookingId)).filter((c) =>
|
||||||
|
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCharge(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
): Promise<BookingClearanceCharge> {
|
||||||
|
const charge = await this.repo().findOne({
|
||||||
|
where: { id: chargeId, bookingId },
|
||||||
|
});
|
||||||
|
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||||
|
async uploadPortDocument(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
this.assertClearanceFinalized(booking);
|
||||||
|
|
||||||
|
const existing = await this.repo().findOne({
|
||||||
|
where: { bookingId, type: 'PORT_CHARGES' },
|
||||||
|
});
|
||||||
|
if (existing && existing.status !== 'DOC_UPLOADED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.filesService.upsertByCode(
|
||||||
|
{
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: CHARGE_FILE_CODE.PORT_CHARGES,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
{ userId: staffId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await this.repo().update(existing.id, {
|
||||||
|
fileRecordId: record.id,
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.repo().save(
|
||||||
|
this.repo().create({
|
||||||
|
bookingId,
|
||||||
|
type: 'PORT_CHARGES',
|
||||||
|
status: 'DOC_UPLOADED',
|
||||||
|
fileRecordId: record.id,
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_PORT_DOC_UPLOADED',
|
||||||
|
label: existing
|
||||||
|
? 'Replaced the port-charges document'
|
||||||
|
: 'Uploaded the port-charges document',
|
||||||
|
actorId: staffId,
|
||||||
|
metadata: { fileName: file.originalname },
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
|
||||||
|
* currency + description. Allowed until the customer accepts: an ACCEPTED
|
||||||
|
* charge already carries an invoice and a PAID one is settled.
|
||||||
|
*/
|
||||||
|
async billCharge(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
input: { amount: number; currency: string; description?: string },
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charge = await this.findCharge(bookingId, chargeId);
|
||||||
|
if (!canStaffEditCharge(charge.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The customer has accepted this charge — it can no longer be changed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException('Amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
if (!input.currency?.trim()) {
|
||||||
|
throw new BadRequestException('Currency is required.');
|
||||||
|
}
|
||||||
|
const description = (input.description ?? charge.description ?? '').trim();
|
||||||
|
if (charge.type === 'MISCELLANEOUS' && !description) {
|
||||||
|
throw new BadRequestException('Describe what this charge is for.');
|
||||||
|
}
|
||||||
|
const currency = input.currency.trim().toUpperCase();
|
||||||
|
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
|
||||||
|
|
||||||
|
// Back to draft: the customer's previous decision no longer applies.
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
amount: input.amount.toFixed(2),
|
||||||
|
currency,
|
||||||
|
description: description || null,
|
||||||
|
status: 'BILLED',
|
||||||
|
customerNote: null,
|
||||||
|
customerDecidedAt: null,
|
||||||
|
customerDecidedBy: null,
|
||||||
|
billedByStaffId: staffId,
|
||||||
|
billedAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_BILLED',
|
||||||
|
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||||
|
charge.type
|
||||||
|
].toLowerCase()}: ${input.amount} ${currency}${
|
||||||
|
description ? ` — ${description}` : ''
|
||||||
|
}`,
|
||||||
|
actorId: staffId,
|
||||||
|
metadata: {
|
||||||
|
chargeType: charge.type,
|
||||||
|
amount: input.amount,
|
||||||
|
currency,
|
||||||
|
description: description || null,
|
||||||
|
revised,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
|
||||||
|
* that is issued when the customer accepts. Re-sending after a rejection
|
||||||
|
* goes through here too.
|
||||||
|
*/
|
||||||
|
async sendCharge(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charge = await this.findCharge(bookingId, chargeId);
|
||||||
|
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
charge.status === 'DOC_UPLOADED'
|
||||||
|
? 'Set the amount and currency before sending the charge to the customer.'
|
||||||
|
: 'This charge has already been sent to the customer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const revised = charge.status === 'REJECTED';
|
||||||
|
const amount = Number(charge.amount);
|
||||||
|
const currency = charge.currency ?? 'ETB';
|
||||||
|
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'SENT',
|
||||||
|
customerNote: null,
|
||||||
|
customerDecidedAt: null,
|
||||||
|
customerDecidedBy: null,
|
||||||
|
});
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_SENT',
|
||||||
|
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
|
||||||
|
charge.type
|
||||||
|
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
|
||||||
|
actorId: staffId ?? null,
|
||||||
|
metadata: {
|
||||||
|
chargeType: charge.type,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description: charge.description ?? null,
|
||||||
|
revised,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.clearanceChargeProposed(booking, {
|
||||||
|
label: CHARGE_LABEL[charge.type],
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description: charge.description ?? null,
|
||||||
|
revised,
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
|
||||||
|
async customerAccept(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||||
|
const charge = await this.findCharge(bookingId, chargeId);
|
||||||
|
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||||
|
? 'This charge has already been accepted.'
|
||||||
|
: 'This charge is not awaiting your decision.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const amount = Number(charge.amount);
|
||||||
|
const currency = charge.currency ?? 'ETB';
|
||||||
|
const invoice = await this.billing.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.ClearanceCharge,
|
||||||
|
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||||
|
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
|
||||||
|
sourceId: charge.id,
|
||||||
|
type: charge.type,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: charge.type,
|
||||||
|
description: `${CHARGE_LABEL[charge.type]} — ${
|
||||||
|
booking.reference ?? bookingId
|
||||||
|
}${charge.description ? `: ${charge.description}` : ''}`,
|
||||||
|
amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
customerNote: null,
|
||||||
|
customerDecidedAt: new Date(),
|
||||||
|
customerDecidedBy: userId,
|
||||||
|
});
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_ACCEPTED',
|
||||||
|
label: `Customer accepted ${CHARGE_LABEL[
|
||||||
|
charge.type
|
||||||
|
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId,
|
||||||
|
metadata: {
|
||||||
|
chargeType: charge.type,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.notifier.clearanceChargeInvoiceIssued(booking, {
|
||||||
|
label: CHARGE_LABEL[charge.type],
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
|
||||||
|
);
|
||||||
|
return this.listForCustomer(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer declines the price with a reason; GL revises and re-sends. */
|
||||||
|
async customerReject(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
note: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||||
|
const charge = await this.findCharge(bookingId, chargeId);
|
||||||
|
if (charge.status !== 'SENT') {
|
||||||
|
throw new ConflictException(
|
||||||
|
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||||
|
? 'This charge has already been accepted.'
|
||||||
|
: 'This charge is not awaiting your decision.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!note?.trim()) {
|
||||||
|
throw new BadRequestException('Say why you are rejecting this charge.');
|
||||||
|
}
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'REJECTED',
|
||||||
|
customerNote: note.trim(),
|
||||||
|
customerDecidedAt: new Date(),
|
||||||
|
customerDecidedBy: userId,
|
||||||
|
});
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_REJECTED',
|
||||||
|
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId,
|
||||||
|
metadata: { chargeType: charge.type, note: note.trim() },
|
||||||
|
});
|
||||||
|
this.notifier.clearanceChargeRejectedToStaff(booking, {
|
||||||
|
label: CHARGE_LABEL[charge.type],
|
||||||
|
note: note.trim(),
|
||||||
|
});
|
||||||
|
return this.listForCustomer(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
|
||||||
|
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
|
||||||
|
*/
|
||||||
|
async createMiscellaneous(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
input: { amount: number; currency: string; description?: string },
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
this.assertClearanceFinalized(booking);
|
||||||
|
|
||||||
|
// No ordering and no cap: a miscellaneous charge may be raised before,
|
||||||
|
// after or alongside the port charge, and a booking may carry several.
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException('Amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
if (!input.currency?.trim()) {
|
||||||
|
throw new BadRequestException('Currency is required.');
|
||||||
|
}
|
||||||
|
const description = input.description?.trim() ?? '';
|
||||||
|
if (!description) {
|
||||||
|
throw new BadRequestException('Describe what this charge is for.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the row first so its id can key the document. A booking may carry
|
||||||
|
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||||
|
// under the same code — a shared code would silently delete the previous
|
||||||
|
// charge's document.
|
||||||
|
const charge = await this.repo().save(
|
||||||
|
this.repo().create({
|
||||||
|
bookingId,
|
||||||
|
type: 'MISCELLANEOUS',
|
||||||
|
status: 'BILLED',
|
||||||
|
amount: input.amount.toFixed(2),
|
||||||
|
currency: input.currency.trim().toUpperCase(),
|
||||||
|
description,
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
billedByStaffId: staffId,
|
||||||
|
billedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const record = await this.filesService.upsertByCode(
|
||||||
|
{
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
{ userId: staffId },
|
||||||
|
);
|
||||||
|
await this.repo().update(charge.id, { fileRecordId: record.id });
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CHARGE_MISC_CREATED',
|
||||||
|
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`,
|
||||||
|
actorId: staffId,
|
||||||
|
metadata: {
|
||||||
|
amount: input.amount,
|
||||||
|
currency: input.currency.trim().toUpperCase(),
|
||||||
|
description,
|
||||||
|
fileName: file.originalname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||||
|
@OnEvent('clearance_charge.invoice.paid')
|
||||||
|
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
const charge = await this.repo().findOne({
|
||||||
|
where: { id: payload.sourceId },
|
||||||
|
});
|
||||||
|
if (!charge || charge.status === 'PAID') return;
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'PAID',
|
||||||
|
paidAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId: charge.bookingId,
|
||||||
|
action: 'CHARGE_PAID',
|
||||||
|
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
|
||||||
|
actorType: 'SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
chargeType: charge.type,
|
||||||
|
invoiceNumber: payload.invoiceNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
CUSTOMER_VISIBLE_CHARGE_STATUSES,
|
||||||
|
canStaffEditCharge,
|
||||||
|
} from './booking-clearance-charge.service';
|
||||||
|
import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity';
|
||||||
|
|
||||||
|
describe('clearance charge status guards', () => {
|
||||||
|
it('locks the charge once the customer has accepted or paid', () => {
|
||||||
|
expect(canStaffEditCharge('ACCEPTED')).toBe(false);
|
||||||
|
expect(canStaffEditCharge('PAID')).toBe(false);
|
||||||
|
for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) {
|
||||||
|
expect(canStaffEditCharge(s)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides GL drafts from the customer and shows everything sent', () => {
|
||||||
|
const visible = CLEARANCE_CHARGE_STATUSES.filter((s) =>
|
||||||
|
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s),
|
||||||
|
);
|
||||||
|
expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A clearance document was queried and needs the customer to re-upload. */
|
/** A clearance document was queried and needs the customer to re-upload. */
|
||||||
|
/** GL asked the customer for additional clearance document(s). */
|
||||||
|
additionalDocsRequested(b: Booking, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`Additional document(s) requested on booking ${b.reference}: ` +
|
||||||
|
`${note} Please upload them from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED');
|
||||||
|
this.inApp(b, 'Additional documents requested', msg, {
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
documentQueried(b: Booking, fileKey: string, note: string): void {
|
documentQueried(b: Booking, fileKey: string, note: string): void {
|
||||||
const msg =
|
const msg =
|
||||||
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
||||||
@@ -410,6 +421,55 @@ export class BookingLifecycleNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clearance charges (port + miscellaneous) ───────────────────────────────
|
||||||
|
|
||||||
|
/** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */
|
||||||
|
clearanceChargeProposed(
|
||||||
|
b: Booking,
|
||||||
|
c: {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
description: string | null;
|
||||||
|
revised: boolean;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const msg =
|
||||||
|
`${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` +
|
||||||
|
`${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` +
|
||||||
|
`await your approval. Please accept or reject them in the portal.`;
|
||||||
|
void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT');
|
||||||
|
this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer accepted a clearance charge — its invoice is now payable. */
|
||||||
|
clearanceChargeInvoiceIssued(
|
||||||
|
b: Booking,
|
||||||
|
c: { label: string; amount: number; currency: string; invoiceNumber: string },
|
||||||
|
): void {
|
||||||
|
const msg =
|
||||||
|
`Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` +
|
||||||
|
`on booking ${b.reference} is ready. Please pay it from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE');
|
||||||
|
this.inApp(b, `${c.label} invoice issued`, msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */
|
||||||
|
clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void {
|
||||||
|
const msg =
|
||||||
|
`The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` +
|
||||||
|
`"${c.note}". Revise and re-send from the clearance page.`;
|
||||||
|
this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/clearance/${b.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** GL confirmed the final-invoice payment slip. */
|
/** GL confirmed the final-invoice payment slip. */
|
||||||
finalInvoicePaid(b: Booking): void {
|
finalInvoicePaid(b: Booking): void {
|
||||||
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
|
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
|
||||||
|
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||||
|
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
|
||||||
|
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'SELECTED_FOR_BATCH',
|
||||||
|
'AWAITING_PAYMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per outstanding item. `invoices.status` / `bookings.status` are
|
||||||
|
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
|
||||||
|
* customer's review (a proposed clearance charge, a draft final invoice) so
|
||||||
|
* they count but do not inflate "amount due".
|
||||||
|
*/
|
||||||
|
const SQL = `
|
||||||
|
-- Central invoices on the booking: freight (only while the booking is in a
|
||||||
|
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
|
||||||
|
-- which waits for the customer's approval).
|
||||||
|
SELECT i.source_id AS "bookingId", i.currency,
|
||||||
|
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
|
||||||
|
FROM freight.invoices i
|
||||||
|
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
|
||||||
|
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
|
||||||
|
AND (
|
||||||
|
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
||||||
|
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
|
||||||
|
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
-- Accepted clearance charges whose invoice is still unpaid.
|
||||||
|
SELECT c.booking_id::text, i.currency, i.balance_amount
|
||||||
|
FROM freight.invoices i
|
||||||
|
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
|
||||||
|
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
|
||||||
|
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
|
||||||
|
UNION ALL
|
||||||
|
-- Clearance charges waiting for the customer to accept or reject the price.
|
||||||
|
SELECT c.booking_id::text, c.currency, NULL::numeric
|
||||||
|
FROM freight.booking_clearance_charge c
|
||||||
|
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
|
||||||
|
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
|
||||||
|
UNION ALL
|
||||||
|
-- Duty / tax advised by customs, payment slip not uploaded yet.
|
||||||
|
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
|
||||||
|
NULLIF(m.metadata->>'dutyAmount', '')::numeric
|
||||||
|
FROM freight.clearance_milestones m
|
||||||
|
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
|
||||||
|
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
|
||||||
|
AND (
|
||||||
|
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.clearance_milestones p
|
||||||
|
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
|
||||||
|
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
||||||
|
OR
|
||||||
|
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.clearance_milestones p
|
||||||
|
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
|
||||||
|
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a customer still has to act on, per booking, in one query. Drives
|
||||||
|
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
|
||||||
|
* composes the same items client-side from the per-booking endpoints.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingPayablesService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async summarizeForCompany(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<Freight.BookingPayableSummary[]> {
|
||||||
|
const rows: Array<{
|
||||||
|
bookingId: string;
|
||||||
|
currency: string | null;
|
||||||
|
amount: string | null;
|
||||||
|
}> = await this.dataSource.query(SQL, [
|
||||||
|
companyId,
|
||||||
|
PAYABLE_INVOICE_STATUSES,
|
||||||
|
FREIGHT_PAYABLE_BOOKING_STATUSES,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byBooking = new Map<string, Freight.BookingPayableSummary>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = byBooking.get(r.bookingId) ?? {
|
||||||
|
bookingId: r.bookingId,
|
||||||
|
count: 0,
|
||||||
|
totals: [],
|
||||||
|
};
|
||||||
|
s.count += 1;
|
||||||
|
const amount = Number(r.amount ?? 0);
|
||||||
|
if (r.currency && amount > 0) {
|
||||||
|
const t = s.totals.find((x) => x.currency === r.currency);
|
||||||
|
if (t) t.amount += amount;
|
||||||
|
else s.totals.push({ currency: r.currency, amount });
|
||||||
|
}
|
||||||
|
byBooking.set(r.bookingId, s);
|
||||||
|
}
|
||||||
|
return [...byBooking.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
: [],
|
: [],
|
||||||
}),
|
}),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
@@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
expect(line!.amount).toBe(200);
|
expect(line!.amount).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
|
||||||
|
const ethiopianFee = {
|
||||||
|
...containerFee20,
|
||||||
|
id: 'rate-et-20',
|
||||||
|
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||||
|
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||||
|
rateValue: 40,
|
||||||
|
} as Rate;
|
||||||
|
// No serviceType relation on the booking (like the GL/portal shipment
|
||||||
|
// preview) — the flag must be resolved from serviceTypeId.
|
||||||
|
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
||||||
|
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
|
||||||
|
};
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
expect(line!.amount).toBe(160);
|
||||||
|
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
const result = await service.computePriceForBooking(containerBooking());
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
@@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
|
|||||||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||||||
}),
|
}),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||||||
@@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
|||||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{ findById: jest.fn() } as never,
|
{ findById: jest.fn() } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const booking = (
|
const booking = (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
|
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
@@ -84,6 +85,7 @@ export class BookingPricingService {
|
|||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly cargoTypesService: CargoTypesService,
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
|
private readonly serviceTypesService: ServiceTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -1060,9 +1062,27 @@ export class BookingPricingService {
|
|||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
|
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||||
|
// contract froze its snapshots under the matching code prefix. Resolved by
|
||||||
|
// id when the relation isn't loaded — the GL / portal shipment previews
|
||||||
|
// price a transient booking object, and a missing relation must not
|
||||||
|
// silently quote the standard fee the created booking is then billed
|
||||||
|
// differently for.
|
||||||
|
const serviceType =
|
||||||
|
booking.serviceType ??
|
||||||
|
(booking.serviceTypeId
|
||||||
|
? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null)
|
||||||
|
: null);
|
||||||
|
const customsType = serviceType?.includesEthiopianCustomsOnly
|
||||||
|
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||||
|
: 'CUSTOMS_CLEARANCE';
|
||||||
|
const customsLabel =
|
||||||
|
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||||
|
? 'Ethiopian customs clearance service'
|
||||||
|
: 'Customs clearance service';
|
||||||
const onLeg = liveRates.filter(
|
const onLeg = liveRates.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
r.rateType === customsType &&
|
||||||
r.currency === 'USD' &&
|
r.currency === 'USD' &&
|
||||||
r.tradeDirection === booking.tradeDirection &&
|
r.tradeDirection === booking.tradeDirection &&
|
||||||
r.originYardId === booking.originYardId &&
|
r.originYardId === booking.originYardId &&
|
||||||
@@ -1070,20 +1090,20 @@ export class BookingPricingService {
|
|||||||
);
|
);
|
||||||
const missingRateMessage = (scope: string): string =>
|
const missingRateMessage = (scope: string): string =>
|
||||||
`No customs clearance service fee is configured for ${scope} on this ` +
|
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||||
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
|
||||||
|
|
||||||
if (booking.freightType === 'CONTAINER') {
|
if (booking.freightType === 'CONTAINER') {
|
||||||
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||||
const hasPerSizeSnapshot =
|
const hasPerSizeSnapshot =
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
frozenRates?.has(`${customsType}_20FT`) ||
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
frozenRates?.has(`${customsType}_40FT`);
|
||||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
const amount = Number(legacyFlat.unitPrice);
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
code: customsType,
|
||||||
description: 'Customs clearance service',
|
description: customsLabel,
|
||||||
amount,
|
amount,
|
||||||
unitAmount: amount,
|
unitAmount: amount,
|
||||||
unit: 'FLAT',
|
unit: 'FLAT',
|
||||||
@@ -1106,7 +1126,7 @@ export class BookingPricingService {
|
|||||||
// unknown type — falls through to the live per-type lookup below
|
// unknown type — falls through to the live per-type lookup below
|
||||||
}
|
}
|
||||||
const frozen = sizeFt
|
const frozen = sizeFt
|
||||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
||||||
: null;
|
: null;
|
||||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
if (!frozen && !live) {
|
if (!frozen && !live) {
|
||||||
@@ -1124,8 +1144,8 @@ export class BookingPricingService {
|
|||||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
if (!(amount > 0)) continue;
|
if (!(amount > 0)) continue;
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
|
||||||
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||||
amount,
|
amount,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
unit,
|
unit,
|
||||||
@@ -1141,7 +1161,7 @@ export class BookingPricingService {
|
|||||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
// commodity-less rate (legacy) is the catch-all fallback.
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||||
const live =
|
const live =
|
||||||
(booking.cargoTypeId
|
(booking.cargoTypeId
|
||||||
? onLeg.find(
|
? onLeg.find(
|
||||||
@@ -1172,8 +1192,8 @@ export class BookingPricingService {
|
|||||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
code: customsType,
|
||||||
description: 'Customs clearance service (bulk)',
|
description: `${customsLabel} (bulk)`,
|
||||||
amount,
|
amount,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
unit,
|
unit,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // containerValidationService
|
{} as never, // containerValidationService
|
||||||
{} as never, // notifier
|
{} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{} as never, // events
|
{} as never, // events
|
||||||
undefined, // milestoneService
|
undefined, // milestoneService
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ import { BookingPricingService } from './booking-pricing.service';
|
|||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { clearanceCodesForBooking } from './clearance.util';
|
import {
|
||||||
|
adHocLabel,
|
||||||
|
clearanceCodesForBooking,
|
||||||
|
clearanceDocumentsOpen,
|
||||||
|
} from './clearance.util';
|
||||||
|
import {
|
||||||
|
buildClearanceDocHistory,
|
||||||
|
type ClearanceDocEvent,
|
||||||
|
} from './clearance-doc-history.util';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -68,6 +77,7 @@ export class BookingTransitionService {
|
|||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||||
@@ -627,8 +637,19 @@ export class BookingTransitionService {
|
|||||||
file: { id: string; name: string; url: string } | null;
|
file: { id: string; name: string; url: string } | null;
|
||||||
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
uploadedAt: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewedByName: string | null;
|
||||||
|
history: ClearanceDocEvent[];
|
||||||
}>;
|
}>;
|
||||||
allApproved: boolean;
|
allApproved: boolean;
|
||||||
|
documentsOpen: boolean;
|
||||||
|
docRequests: Array<{
|
||||||
|
id: string;
|
||||||
|
note: string;
|
||||||
|
byName: string | null;
|
||||||
|
at: string;
|
||||||
|
}>;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
milestones?: unknown[];
|
milestones?: unknown[];
|
||||||
nextAction?: unknown;
|
nextAction?: unknown;
|
||||||
@@ -652,6 +673,23 @@ export class BookingTransitionService {
|
|||||||
const reviewByKey = new Map(
|
const reviewByKey = new Map(
|
||||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||||
);
|
);
|
||||||
|
const allVersions = await this.filesService.findAllVersionsByResource(
|
||||||
|
bookingId,
|
||||||
|
"bookings",
|
||||||
|
);
|
||||||
|
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
||||||
|
bookingId,
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
);
|
||||||
|
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||||
|
bookingId,
|
||||||
|
"ADDITIONAL_DOC_REQUEST",
|
||||||
|
);
|
||||||
|
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||||
|
...reviews.map((r) => r.reviewedByStaffId),
|
||||||
|
...queryNotes.map((n) => n.authorId),
|
||||||
|
...docRequestNotes.map((n) => n.authorId),
|
||||||
|
]);
|
||||||
|
|
||||||
const documents: Awaited<
|
const documents: Awaited<
|
||||||
ReturnType<BookingTransitionService["getClearanceView"]>
|
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||||
@@ -680,6 +718,18 @@ export class BookingTransitionService {
|
|||||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
|
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByName: review?.reviewedByStaffId
|
||||||
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
history: buildClearanceDocHistory({
|
||||||
|
fileKey: field.fileKey,
|
||||||
|
allVersions,
|
||||||
|
queryNotes,
|
||||||
|
review,
|
||||||
|
names: reviewerNames,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -693,13 +743,27 @@ export class BookingTransitionService {
|
|||||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
documents.push({
|
documents.push({
|
||||||
fileKey: f.code,
|
fileKey: f.code,
|
||||||
label: f.name,
|
// What the customer called it, falling back to the filename for rows
|
||||||
|
// uploaded before the name was carried through.
|
||||||
|
label: f.title || adHocLabel(f.code) || f.name,
|
||||||
required: false,
|
required: false,
|
||||||
uploadedBy: "customer",
|
uploadedBy: "customer",
|
||||||
settingCode: "custom",
|
settingCode: "custom",
|
||||||
file: { id: f.id, name: f.name, url: f.url },
|
file: { id: f.id, name: f.name, url: f.url },
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
|
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByName: review?.reviewedByStaffId
|
||||||
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
history: buildClearanceDocHistory({
|
||||||
|
fileKey: f.code,
|
||||||
|
allVersions,
|
||||||
|
queryNotes,
|
||||||
|
review,
|
||||||
|
names: reviewerNames,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,9 +776,52 @@ export class BookingTransitionService {
|
|||||||
outputCode,
|
outputCode,
|
||||||
documents,
|
documents,
|
||||||
allApproved,
|
allApproved,
|
||||||
|
documentsOpen: clearanceDocumentsOpen(booking),
|
||||||
|
docRequests: docRequestNotes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
note: n.note,
|
||||||
|
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||||
|
at: n.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL asks the customer for additional clearance document(s). Stored as a
|
||||||
|
* review-note thread shown on both the GL clearance page and the customer's
|
||||||
|
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
|
||||||
|
* documents are open (until the shipment is paid).
|
||||||
|
*/
|
||||||
|
async requestAdditionalDocuments(
|
||||||
|
bookingId: string,
|
||||||
|
note: string,
|
||||||
|
staffId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
if (!clearanceDocumentsOpen(booking)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!note?.trim()) {
|
||||||
|
throw new BadRequestException("Describe the document(s) you need.");
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note.trim(),
|
||||||
|
"ADDITIONAL_DOC_REQUEST",
|
||||||
|
staffId,
|
||||||
|
);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: "ADDITIONAL_DOCS_REQUESTED",
|
||||||
|
label: "Requested additional document(s) from the customer",
|
||||||
|
actorId: staffId,
|
||||||
|
metadata: { note: note.trim() },
|
||||||
|
});
|
||||||
|
this.notifier.additionalDocsRequested(booking, note.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when every REQUIRED field of the booking's customer-input clearance set
|
* True when every REQUIRED field of the booking's customer-input clearance set
|
||||||
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
||||||
@@ -751,12 +858,17 @@ export class BookingTransitionService {
|
|||||||
async submitClearanceDocuments(
|
async submitClearanceDocuments(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
// Documents stay open until the shipment is paid — a customs shipment keeps
|
||||||
"AWAITING_DOCUMENTS",
|
// collecting paperwork (amended invoices, port documents) well past
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
// clearance finalization. See {@link clearanceDocumentsOpen}.
|
||||||
]);
|
if (!clearanceDocumentsOpen(booking)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { inputCode } = clearanceCodesForBooking(booking);
|
const { inputCode } = clearanceCodesForBooking(booking);
|
||||||
if (!inputCode) {
|
if (!inputCode) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -781,6 +893,10 @@ export class BookingTransitionService {
|
|||||||
resource: "bookings",
|
resource: "bookings",
|
||||||
code: file.fieldname,
|
code: file.fieldname,
|
||||||
file,
|
file,
|
||||||
|
// Ad-hoc uploads carry the name the customer typed (fieldname
|
||||||
|
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
|
||||||
|
// of a raw filename like "scan_003.pdf".
|
||||||
|
title: adHocLabel(file.fieldname),
|
||||||
});
|
});
|
||||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||||
const settingCode = file.fieldname.startsWith("custom_")
|
const settingCode = file.fieldname.startsWith("custom_")
|
||||||
@@ -794,6 +910,15 @@ export class BookingTransitionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the pre-finalization submission drives the booking into review.
|
||||||
|
// A later addition (an amended invoice while the shipment is already
|
||||||
|
// scheduled) must never rewind the status or reopen the phased workflow —
|
||||||
|
// it lands as a new PENDING document for GL to approve where it stands.
|
||||||
|
const inDocumentPhase =
|
||||||
|
booking.status === "AWAITING_DOCUMENTS" ||
|
||||||
|
booking.status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
|
||||||
|
if (inDocumentPhase) {
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "DOCUMENTS_UNDER_REVIEW",
|
status: "DOCUMENTS_UNDER_REVIEW",
|
||||||
} as never);
|
} as never);
|
||||||
@@ -808,6 +933,17 @@ export class BookingTransitionService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileKeys = files.map((f) => f.fieldname);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DOCS_SUBMITTED',
|
||||||
|
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { fileKeys },
|
||||||
|
});
|
||||||
|
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
||||||
@@ -861,7 +997,14 @@ export class BookingTransitionService {
|
|||||||
note?: string,
|
note?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
// GL keeps reviewing for as long as the customer can still submit — the
|
||||||
|
// two sides share one predicate so they can never drift apart. Documents
|
||||||
|
// added after clearance was finalized still need approving/querying.
|
||||||
|
if (!clearanceDocumentsOpen(booking)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||||
|
|
||||||
const existing =
|
const existing =
|
||||||
@@ -878,15 +1021,6 @@ export class BookingTransitionService {
|
|||||||
"A note is required when querying a document",
|
"A note is required when querying a document",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
status === 'QUERIED' &&
|
|
||||||
this.isPhasedCustoms(booking) &&
|
|
||||||
booking.preClearanceFinalizedAt
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.bookingsRepository.setDocumentReviewStatus(
|
await this.bookingsRepository.setDocumentReviewStatus(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -896,6 +1030,16 @@ export class BookingTransitionService {
|
|||||||
staffId,
|
staffId,
|
||||||
note,
|
note,
|
||||||
);
|
);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
|
||||||
|
label:
|
||||||
|
status === 'APPROVED'
|
||||||
|
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
|
||||||
|
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
|
||||||
|
actorId: staffId,
|
||||||
|
metadata: { fileKey, note: note ?? null },
|
||||||
|
});
|
||||||
if (status === "QUERIED") {
|
if (status === "QUERIED") {
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -903,7 +1047,10 @@ export class BookingTransitionService {
|
|||||||
"CHANGES_REQUESTED",
|
"CHANGES_REQUESTED",
|
||||||
staffId,
|
staffId,
|
||||||
);
|
);
|
||||||
if (this.isPhasedCustoms(booking)) {
|
// Reopening the review phase only makes sense while clearance is still
|
||||||
|
// being decided. Querying a document that arrived afterwards must not
|
||||||
|
// drag a finalized shipment back into the GL review phase.
|
||||||
|
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
@@ -915,7 +1062,10 @@ export class BookingTransitionService {
|
|||||||
if (status === "QUERIED") {
|
if (status === "QUERIED") {
|
||||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||||
}
|
}
|
||||||
if (this.isPhasedCustoms(updated)) {
|
// Same reasoning as the query branch: advance the workflow only while
|
||||||
|
// clearance is still open. Approving a late-added document leaves an
|
||||||
|
// already-finalized shipment's phase exactly where it is.
|
||||||
|
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
|
||||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||||
@@ -936,6 +1086,7 @@ export class BookingTransitionService {
|
|||||||
async uploadClearanceOutputDocuments(
|
async uploadClearanceOutputDocuments(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||||
@@ -956,6 +1107,15 @@ export class BookingTransitionService {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'OUTPUT_DOCS_UPLOADED',
|
||||||
|
label: `Uploaded customs output document(s): ${files
|
||||||
|
.map((f) => f.fieldname.replace(/_/g, ' '))
|
||||||
|
.join(', ')}`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { fileKeys: files.map((f) => f.fieldname) },
|
||||||
|
});
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,7 +1123,7 @@ export class BookingTransitionService {
|
|||||||
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
||||||
* and, for customs, the required output documents present → CLEARANCE_READY.
|
* and, for customs, the required output documents present → CLEARANCE_READY.
|
||||||
*/
|
*/
|
||||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1019,6 +1179,12 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CLEARANCE_READY",
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'CLEARANCE_FINALIZED',
|
||||||
|
label: 'Finalized document approval — clearance ready',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.clearanceReady(fresh);
|
this.notifier.clearanceReady(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
@@ -1045,6 +1211,8 @@ export class BookingTransitionService {
|
|||||||
* the customer pools, so the gate here would wrongly reject them).
|
* the customer pools, so the gate here would wrongly reject them).
|
||||||
*/
|
*/
|
||||||
bypassDayPool?: boolean;
|
bypassDayPool?: boolean;
|
||||||
|
/** Acting user, recorded in the clearance history. */
|
||||||
|
userId?: string;
|
||||||
},
|
},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
@@ -1159,6 +1327,14 @@ export class BookingTransitionService {
|
|||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
requestedTrainScheduleId: requestedId,
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'OPERATION_REQUESTED',
|
||||||
|
label: `Requested operation for shipment day ${scheduledDate}`,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: opts?.userId ?? null,
|
||||||
|
metadata: { scheduledDate },
|
||||||
|
});
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -38,6 +39,16 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
|
import { BookingPayablesService } from './booking-payables.service';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
|
import {
|
||||||
|
|
||||||
|
RejectClearanceChargeDto,
|
||||||
|
} from './dto/clearance-charge.dto';
|
||||||
|
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||||
|
import { AdditionalChargeService } from './additional-charge.service';
|
||||||
|
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
@@ -170,6 +181,10 @@ export class BookingsController {
|
|||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
|
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||||
|
private readonly bookingPayablesService: BookingPayablesService,
|
||||||
|
private readonly clearanceEventService: ClearanceEventService,
|
||||||
|
private readonly additionalChargeService: AdditionalChargeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -306,6 +321,21 @@ export class BookingsController {
|
|||||||
return this.bookingsService.getListSummary(filter);
|
return this.bookingsService.getListSummary(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("my-payables")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
|
||||||
|
})
|
||||||
|
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
const companyId = await this.bookingsService.resolveCustomerCompanyId(
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return companyId
|
||||||
|
? this.bookingPayablesService.summarizeForCompany(companyId)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
@Get("my")
|
@Get("my")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -971,10 +1001,12 @@ export class BookingsController {
|
|||||||
async submitClearanceDocuments(
|
async submitClearanceDocuments(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.submitClearanceDocuments(
|
const booking = await this.transitionService.submitClearanceDocuments(
|
||||||
id,
|
id,
|
||||||
files ?? [],
|
files ?? [],
|
||||||
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -991,11 +1023,13 @@ export class BookingsController {
|
|||||||
async proceedToOperation(
|
async proceedToOperation(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestOperationDto,
|
@Body() dto: RequestOperationDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.requestOperation(
|
const booking = await this.transitionService.requestOperation(
|
||||||
id,
|
id,
|
||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
dto.trainScheduleId ?? null,
|
dto.trainScheduleId ?? null,
|
||||||
|
{ userId: resolveAuthUserId(user) },
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1107,239 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/doc-requests")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
|
||||||
|
})
|
||||||
|
async requestAdditionalDocuments(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body("note") note: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
await this.transitionService.requestAdditionalDocuments(
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/clearance/history")
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||||
|
})
|
||||||
|
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceEventService.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||||
|
|
||||||
|
@Get(":id/clearance/charges")
|
||||||
|
@MixedAudience([
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
|
||||||
|
})
|
||||||
|
async getClearanceCharges(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const isStaff =
|
||||||
|
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||||
|
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||||
|
if (isStaff) return this.clearanceChargeService.list(id);
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
return this.clearanceChargeService.listForCustomer(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/:chargeId/accept")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
|
||||||
|
})
|
||||||
|
acceptClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.customerAccept(
|
||||||
|
id,
|
||||||
|
chargeId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/:chargeId/reject")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
|
||||||
|
})
|
||||||
|
rejectClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@Body() dto: RejectClearanceChargeDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.customerReject(
|
||||||
|
id,
|
||||||
|
chargeId,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/port-document")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
|
||||||
|
})
|
||||||
|
uploadPortChargeDocument(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException("A document file is required");
|
||||||
|
return this.clearanceChargeService.uploadPortDocument(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/clearance/charges/:chargeId/bill")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
|
||||||
|
})
|
||||||
|
billClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@Body() dto: BillClearanceChargeDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.billCharge(
|
||||||
|
id,
|
||||||
|
chargeId,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/:chargeId/send")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
|
||||||
|
})
|
||||||
|
sendClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.sendCharge(
|
||||||
|
id,
|
||||||
|
chargeId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/miscellaneous")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
|
||||||
|
})
|
||||||
|
createMiscellaneousCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body() dto: BillClearanceChargeDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException("A document file is required");
|
||||||
|
return this.clearanceChargeService.createMiscellaneous(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Additional charges (ad-hoc finance billing) ────────────────────────────
|
||||||
|
|
||||||
|
@Get(":id/additional-charges")
|
||||||
|
@MixedAudience(FREIGHT_PERMS.additionalCharges.view)
|
||||||
|
@ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" })
|
||||||
|
async getAdditionalCharges(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view);
|
||||||
|
if (!isStaff) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
const charges = await this.additionalChargeService.list(id);
|
||||||
|
// A charge finance hasn't sent yet isn't the customer's to see.
|
||||||
|
return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/additional-charges")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.additionalCharges.create)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Finance raises a new additional charge — draft, or send to the customer immediately",
|
||||||
|
})
|
||||||
|
createAdditionalCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body() dto: CreateAdditionalChargeDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/additional-charges/:chargeId/send")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.additionalCharges.send)
|
||||||
|
@ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" })
|
||||||
|
sendAdditionalCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/additional-charges/:chargeId/cancel")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.additionalCharges.cancel)
|
||||||
|
@ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" })
|
||||||
|
cancelAdditionalCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@Body() dto: CancelAdditionalChargeDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/clearance/output-documents")
|
@Post(":id/clearance/output-documents")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@@ -1081,10 +1348,12 @@ export class BookingsController {
|
|||||||
async uploadClearanceOutput(
|
async uploadClearanceOutput(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||||
id,
|
id,
|
||||||
files ?? [],
|
files ?? [],
|
||||||
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -1095,8 +1364,14 @@ export class BookingsController {
|
|||||||
summary:
|
summary:
|
||||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||||
})
|
})
|
||||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
async finalizeClearance(
|
||||||
const booking = await this.transitionService.finalizeClearance(id);
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.finalizeClearance(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,8 +1384,13 @@ export class BookingsController {
|
|||||||
async requestBookingTransitAssignee(
|
async requestBookingTransitAssignee(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body('note') note: string | undefined,
|
@Body('note') note: string | undefined,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
|
const booking = await this.bookingClearanceService.requestTransitAssignee(
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,8 +1403,13 @@ export class BookingsController {
|
|||||||
async assignBookingTransitAssignee(
|
async assignBookingTransitAssignee(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
|
const booking = await this.bookingClearanceService.assignTransitAssignee(
|
||||||
|
id,
|
||||||
|
transitAgentId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,8 +1493,14 @@ export class BookingsController {
|
|||||||
summary:
|
summary:
|
||||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||||
})
|
})
|
||||||
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
|
async acceptBookingDraftDeclaration(
|
||||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,8 +1526,14 @@ export class BookingsController {
|
|||||||
@Post(':id/clearance/finalize-pre-clearance')
|
@Post(':id/clearance/finalize-pre-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
async finalizeBookingPreClearance(
|
||||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.finalizePreClearance(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,8 +1545,13 @@ export class BookingsController {
|
|||||||
async uploadBookingDutySlip(
|
async uploadBookingDutySlip(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
const booking = await this.bookingClearanceService.uploadDutySlip(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ import { ContainerValidationService } from './container-validation.service';
|
|||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
|
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||||
|
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||||
|
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||||
|
import { AdditionalChargeService } from './additional-charge.service';
|
||||||
|
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
|
import { BookingPayablesService } from './booking-payables.service';
|
||||||
|
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
@@ -76,6 +84,9 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckAssignment,
|
CustomerTruckAssignment,
|
||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
ConsolidationApproval,
|
ConsolidationApproval,
|
||||||
|
BookingClearanceCharge,
|
||||||
|
BookingClearanceEvent,
|
||||||
|
AdditionalCharge,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -111,6 +122,11 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
BookingClearanceChargeService,
|
||||||
|
BookingPayablesService,
|
||||||
|
ClearanceEventService,
|
||||||
|
AdditionalChargeRepository,
|
||||||
|
AdditionalChargeService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
@@ -126,6 +142,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
|
ClearanceEventService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
ContainerValidationService,
|
ContainerValidationService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||||
|
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
@@ -78,8 +79,10 @@ export interface BookingListFilterOptions {
|
|||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
scheduledFrom?: string;
|
scheduledFrom?: string;
|
||||||
scheduledTo?: string;
|
scheduledTo?: string;
|
||||||
originYardId?: string;
|
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||||
destinationYardId?: string;
|
originYardId?: string[];
|
||||||
|
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||||
|
destinationYardId?: string[];
|
||||||
isGovernment?: 'true' | 'false';
|
isGovernment?: 'true' | 'false';
|
||||||
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
||||||
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
||||||
@@ -619,6 +622,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookings (of those given) that have at least one customer document still
|
||||||
|
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
|
||||||
|
* which no milestone tracks, so a file added after clearance was finalized
|
||||||
|
* still surfaces as needing review. One query for a whole queue page.
|
||||||
|
*/
|
||||||
|
async findBookingsWithUnreviewedDocuments(
|
||||||
|
bookingIds: string[],
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (bookingIds.length === 0) return new Set();
|
||||||
|
const rows = (await this.dataSource
|
||||||
|
.getRepository(BookingDocumentReview)
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select('DISTINCT r.booking_id', 'bookingId')
|
||||||
|
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
|
||||||
|
.andWhere('r.status IN (:...statuses)', {
|
||||||
|
statuses: ['PENDING', 'QUERIED'],
|
||||||
|
})
|
||||||
|
.andWhere('r.deleted_at IS NULL')
|
||||||
|
.getRawMany()) as Array<{ bookingId: string }>;
|
||||||
|
return new Set(rows.map((r) => r.bookingId));
|
||||||
|
}
|
||||||
|
|
||||||
findDocumentReview(
|
findDocumentReview(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
settingCode: string,
|
settingCode: string,
|
||||||
@@ -660,6 +686,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await repo.save(repo.create({ ...input, status: 'PENDING' }));
|
await repo.save(repo.create({ ...input, status: 'PENDING' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display names for reviewer staff ids — one query for the whole set. */
|
||||||
|
async resolveStaffNames(
|
||||||
|
staffIds: (string | null | undefined)[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
return resolveIamUserNames(this.dataSource, staffIds);
|
||||||
|
}
|
||||||
|
|
||||||
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||||
async setDocumentReviewStatus(
|
async setDocumentReviewStatus(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -1120,14 +1153,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
scheduledTo: options.scheduledTo,
|
scheduledTo: options.scheduledTo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.originYardId) {
|
// Each end is its own OR-list, and the two ends AND together — so
|
||||||
qb.andWhere('booking.origin_yard_id = :originYardId', {
|
// "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
|
||||||
originYardId: options.originYardId,
|
// expressible. `?.length` guards the empty array: `IN ()` is a syntax error.
|
||||||
|
if (options.originYardId?.length) {
|
||||||
|
qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', {
|
||||||
|
originYardIds: options.originYardId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.destinationYardId) {
|
if (options.destinationYardId?.length) {
|
||||||
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
|
qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
|
||||||
destinationYardId: options.destinationYardId,
|
destinationYardIds: options.destinationYardId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.isGovernment === 'true') {
|
if (options.isGovernment === 'true') {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
|
import type { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
|
import type { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
|
/** One entry of a clearance document's per-card audit trail, oldest first. */
|
||||||
|
export interface ClearanceDocEvent {
|
||||||
|
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
|
||||||
|
at: string;
|
||||||
|
byName: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query review notes are written as `Document "<fileKey>" queried: <note>`
|
||||||
|
* (see BookingTransitionService.reviewDocument) — the only place a past query
|
||||||
|
* decision survives after the customer re-uploads and the review row resets.
|
||||||
|
*/
|
||||||
|
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document audit trail assembled from data the flow already persists:
|
||||||
|
* every stored file version (first = customer upload, later ones = the
|
||||||
|
* customer's amendment responses), every query note (who opened it, when,
|
||||||
|
* why), and the review row's current approval. Approvals that were later
|
||||||
|
* reset by a re-upload are the one thing not kept anywhere — the trail shows
|
||||||
|
* the decision that currently stands.
|
||||||
|
*/
|
||||||
|
export function buildClearanceDocHistory(input: {
|
||||||
|
fileKey: string;
|
||||||
|
/** All versions of all files on the booking, createdAt ASC, deleted included. */
|
||||||
|
allVersions: FileRecord[];
|
||||||
|
/** CHANGES_REQUESTED review notes for the booking. */
|
||||||
|
queryNotes: BookingReviewNote[];
|
||||||
|
review: BookingDocumentReview | null;
|
||||||
|
/** staff id → display name. */
|
||||||
|
names: Map<string, string>;
|
||||||
|
}): ClearanceDocEvent[] {
|
||||||
|
const { fileKey, allVersions, queryNotes, review, names } = input;
|
||||||
|
const events: ClearanceDocEvent[] = [];
|
||||||
|
|
||||||
|
const versions = allVersions.filter((v) => v.code === fileKey);
|
||||||
|
versions.forEach((v, i) => {
|
||||||
|
events.push({
|
||||||
|
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
|
||||||
|
at: v.createdAt.toISOString(),
|
||||||
|
byName: v.uploadedByName ?? null,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const n of queryNotes) {
|
||||||
|
const m = QUERY_NOTE_RE.exec(n.note);
|
||||||
|
if (!m || m[1] !== fileKey) continue;
|
||||||
|
events.push({
|
||||||
|
type: 'QUERIED',
|
||||||
|
at: n.createdAt.toISOString(),
|
||||||
|
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
|
||||||
|
note: m[2] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (review?.status === 'APPROVED' && review.reviewedAt) {
|
||||||
|
events.push({
|
||||||
|
type: 'APPROVED',
|
||||||
|
at: review.reviewedAt.toISOString(),
|
||||||
|
byName: review.reviewedByStaffId
|
||||||
|
? (names.get(review.reviewedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { clearanceDocumentsOpen } from './clearance.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer may attach clearance documents — and GL may review them — right
|
||||||
|
* up to payment, not merely until clearance is finalized. Both the upload and
|
||||||
|
* the review endpoint gate on this one predicate, so a drift here silently
|
||||||
|
* desynchronizes the two sides.
|
||||||
|
*/
|
||||||
|
const booking = (patch: Partial<Booking>): Booking =>
|
||||||
|
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
|
||||||
|
|
||||||
|
describe('clearanceDocumentsOpen', () => {
|
||||||
|
it('stays open across the whole pre-payment flow', () => {
|
||||||
|
for (const status of [
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
'OPERATION_REQUEST_PENDING',
|
||||||
|
'SELECTED_FOR_BATCH',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
]) {
|
||||||
|
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes once the shipment is paid or finished', () => {
|
||||||
|
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
|
||||||
|
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on a dead booking', () => {
|
||||||
|
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
|
||||||
|
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when payment settled before the status caught up', () => {
|
||||||
|
expect(
|
||||||
|
clearanceDocumentsOpen(
|
||||||
|
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||||
|
import {
|
||||||
|
BookingClearanceEvent,
|
||||||
|
ClearanceEventActorType,
|
||||||
|
} from './entities/booking-clearance-event.entity';
|
||||||
|
|
||||||
|
export interface RecordClearanceEventInput {
|
||||||
|
bookingId: string;
|
||||||
|
action: string;
|
||||||
|
/** Human sentence for the History tab, frozen at write time. */
|
||||||
|
label: string;
|
||||||
|
actorType?: ClearanceEventActorType;
|
||||||
|
/** IAM user id (staff or portal customer); name is resolved here. */
|
||||||
|
actorId?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
|
||||||
|
manager?: EntityManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clearance History tab's write/read path. Every clearance mutation calls
|
||||||
|
* {@link record} — document reviews, phased workflow steps, customer charges.
|
||||||
|
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
|
||||||
|
* transaction when a manager is passed, and otherwise a failed insert fails the
|
||||||
|
* action, because a silent gap in an audit trail is worse than a retry.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ClearanceEventService {
|
||||||
|
private readonly logger = new Logger(ClearanceEventService.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async record(input: RecordClearanceEventInput): Promise<void> {
|
||||||
|
const mg = input.manager ?? this.dataSource.manager;
|
||||||
|
const actorName = input.actorId
|
||||||
|
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
|
||||||
|
input.actorId,
|
||||||
|
) ?? null)
|
||||||
|
: null;
|
||||||
|
await mg.save(
|
||||||
|
mg.create(BookingClearanceEvent, {
|
||||||
|
bookingId: input.bookingId,
|
||||||
|
action: input.action,
|
||||||
|
label: input.label,
|
||||||
|
actorType: input.actorType ?? 'STAFF',
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
actorName,
|
||||||
|
metadata: input.metadata ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`clearance-history ${input.action} on booking ${input.bookingId}${
|
||||||
|
actorName ? ` by ${actorName}` : ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History for one booking, newest first. */
|
||||||
|
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
|
||||||
|
const rows = await this.dataSource
|
||||||
|
.getRepository(BookingClearanceEvent)
|
||||||
|
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||||
|
|
||||||
|
// Rows whose actor name failed to resolve at write time get one more try.
|
||||||
|
const missing = rows
|
||||||
|
.filter((r) => !r.actorName && r.actorId)
|
||||||
|
.map((r) => r.actorId as string);
|
||||||
|
const names = missing.length
|
||||||
|
? await resolveIamUserNames(this.dataSource, missing).catch(
|
||||||
|
() => new Map<string, string>(),
|
||||||
|
)
|
||||||
|
: new Map<string, string>();
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
action: r.action,
|
||||||
|
label: r.label,
|
||||||
|
actorType: r.actorType,
|
||||||
|
actorName:
|
||||||
|
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
|
||||||
|
metadata: r.metadata ?? null,
|
||||||
|
at: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,3 +113,53 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
includesCustoms,
|
includesCustoms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses after which clearance documents are closed: the shipment is paid
|
||||||
|
* and moving. Everything before that — review, clearance ready, operation
|
||||||
|
* request, batch selection, PNR, payment verification — still accepts new
|
||||||
|
* customer documents and still lets GL review them.
|
||||||
|
*/
|
||||||
|
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
|
||||||
|
'PAID',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'ARRIVED',
|
||||||
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED',
|
||||||
|
'EXPIRED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the customer may still attach clearance documents and GL may
|
||||||
|
* still approve or query them.
|
||||||
|
*
|
||||||
|
* Clearance finalization is NOT the cut-off: a customs shipment keeps
|
||||||
|
* collecting paperwork (amended invoices, revised packing lists, port
|
||||||
|
* documents) right up to the final invoice being settled. Both the customer's
|
||||||
|
* upload endpoint and GL's review endpoint gate on this one predicate, so the
|
||||||
|
* two sides can never drift apart.
|
||||||
|
*/
|
||||||
|
export function clearanceDocumentsOpen(booking: Booking): boolean {
|
||||||
|
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
|
||||||
|
// Payment settled ahead of the status transition (webhook ordering).
|
||||||
|
if (booking.paymentStatus === 'PAID') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label the customer typed for an ad-hoc clearance document, recovered from
|
||||||
|
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
|
||||||
|
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
|
||||||
|
* back to the filename.
|
||||||
|
*/
|
||||||
|
export function adHocLabel(fileKey: string): string | null {
|
||||||
|
const m = /^custom_(.+)_\d+$/.exec(fileKey);
|
||||||
|
if (!m) return null;
|
||||||
|
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
|
||||||
|
// of digits. Those carry no name — reject them so the caller falls back to
|
||||||
|
// the filename instead of showing "1755780000000".
|
||||||
|
if (/^\d+$/.test(m[1])) return null;
|
||||||
|
const label = m[1].replace(/-/g, ' ').trim();
|
||||||
|
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAdditionalChargeDto {
|
||||||
|
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 2000)
|
||||||
|
reason!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 4500 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 8)
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
/** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */
|
||||||
|
@ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['draft', 'send'])
|
||||||
|
action?: 'draft' | 'send';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelAdditionalChargeDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Raised in error' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 2000)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class BillClearanceChargeDto {
|
||||||
|
@ApiProperty({ example: 12500.5 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 8)
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
/** What the price is for. Required for miscellaneous charges (checked in the service). */
|
||||||
|
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectClearanceChargeDto {
|
||||||
|
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
note!: string;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TRADE_DIRECTIONS,
|
TRADE_DIRECTIONS,
|
||||||
} from './create-booking.dto';
|
} from './create-booking.dto';
|
||||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||||
|
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||||
|
|
||||||
export class FilterBookingDto {
|
export class FilterBookingDto {
|
||||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||||
@@ -96,15 +97,23 @@ export class FilterBookingDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledTo?: string;
|
scheduledTo?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Filter by origin yard — one id or a comma-separated list; a booking matches if it leaves ANY of them.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
originYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
originYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Filter by destination yard — one id or a comma-separated list; a booking matches if it arrives at ANY of them. Combined with originYardId by AND.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
destinationYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
destinationYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const ADDITIONAL_CHARGE_STATUSES = [
|
||||||
|
'DRAFT',
|
||||||
|
'SENT',
|
||||||
|
'PAID',
|
||||||
|
'CANCELLED',
|
||||||
|
] as const;
|
||||||
|
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ad-hoc extra charge finance raises against a booking — free-text reason,
|
||||||
|
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
|
||||||
|
* per type). DRAFT until finance sends it; sending issues the payable invoice
|
||||||
|
* and notifies the customer (in-app + SMS + email). PAID via the standard
|
||||||
|
* `additional_charge.invoice.paid` settlement event.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'additional_charge' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class AdditionalCharge extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text' })
|
||||||
|
reason!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||||
|
status!: AdditionalChargeStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
|
||||||
|
amount!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'currency', type: 'varchar', length: 8 })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
/** The supporting attachment (FileRecord), if any. */
|
||||||
|
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||||
|
fileRecordId?: string | null;
|
||||||
|
|
||||||
|
/** The payable invoice issued for this charge (null until SENT). */
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||||
|
invoiceId?: string | null;
|
||||||
|
|
||||||
|
/** CBE bill reference / PNR the customer pays against, once issued. */
|
||||||
|
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
|
||||||
|
paymentReference?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
createdByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
sentByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
|
||||||
|
sentAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
paidAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
cancelledByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||||
|
cancelledAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
|
||||||
|
cancelReason?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
|
||||||
|
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
|
||||||
|
|
||||||
|
export const CLEARANCE_CHARGE_STATUSES = [
|
||||||
|
'DOC_UPLOADED',
|
||||||
|
'BILLED',
|
||||||
|
'SENT',
|
||||||
|
'REJECTED',
|
||||||
|
'ACCEPTED',
|
||||||
|
'PAID',
|
||||||
|
] as const;
|
||||||
|
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
|
||||||
|
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
|
||||||
|
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
|
||||||
|
* sets amount + currency + description (BILLED) and proposes it to the
|
||||||
|
* customer (SENT). The customer either REJECTS with a note (GL revises and
|
||||||
|
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
|
||||||
|
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
|
||||||
|
* are independent — either may be raised first.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class BookingClearanceCharge extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||||
|
type!: ClearanceChargeType;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
|
||||||
|
status!: ClearanceChargeStatus;
|
||||||
|
|
||||||
|
/** The supporting charge document (FileRecord). */
|
||||||
|
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||||
|
fileRecordId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
|
amount?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||||
|
currency?: string | null;
|
||||||
|
|
||||||
|
/** What the price is for, written by GL. */
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
/** Customer's reason when REJECTED; cleared when GL revises. */
|
||||||
|
@Column({ name: 'customer_note', type: 'text', nullable: true })
|
||||||
|
customerNote?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
|
||||||
|
customerDecidedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
|
||||||
|
customerDecidedBy?: string | null;
|
||||||
|
|
||||||
|
/** The payable invoice issued for this charge (null until SENT). */
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||||
|
invoiceId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
uploadedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
uploadedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
billedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
|
||||||
|
billedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
paidAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
|
||||||
|
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per action in a booking's clearance flow — the History tab's source
|
||||||
|
* of truth. Written explicitly (and, where the caller runs one, inside the
|
||||||
|
* caller's transaction) by every clearance mutation: document review, phased
|
||||||
|
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
|
||||||
|
* charges. `action` is a stable machine code; `label` is the human sentence
|
||||||
|
* rendered as written, so old rows survive later wording changes.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
|
||||||
|
@Index(['bookingId', 'createdAt'])
|
||||||
|
export class BookingClearanceEvent extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 64 })
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
/** Human sentence shown in the History tab, frozen at write time. */
|
||||||
|
@Column({ name: 'label', type: 'varchar', length: 500 })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
|
||||||
|
actorType!: ClearanceEventActorType;
|
||||||
|
|
||||||
|
/** IAM user id of the actor (null for SYSTEM events). */
|
||||||
|
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||||
|
actorId?: string | null;
|
||||||
|
|
||||||
|
/** Display name resolved at write time (iam.users); null when unresolvable. */
|
||||||
|
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
|
||||||
|
actorName?: string | null;
|
||||||
|
|
||||||
|
/** Action details: fileKey, note, amount, currency, file names, … */
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
|
|||||||
* (price/files). One row per round — the draft/change-request loop can repeat.
|
* (price/files). One row per round — the draft/change-request loop can repeat.
|
||||||
*/
|
*/
|
||||||
'DRAFT_DECL_CHANGE_REQUEST',
|
'DRAFT_DECL_CHANGE_REQUEST',
|
||||||
|
/**
|
||||||
|
* GL asked the customer for additional clearance document(s). Shown as a
|
||||||
|
* thread on both the GL clearance page and the customer's portal — the
|
||||||
|
* customer answers by uploading an ad-hoc document.
|
||||||
|
*/
|
||||||
|
'ADDITIONAL_DOC_REQUEST',
|
||||||
] as const;
|
] as const;
|
||||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ function makeService(overrides?: {
|
|||||||
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(booking),
|
update: jest.fn().mockResolvedValue(booking),
|
||||||
findByStatuses: jest.fn().mockResolvedValue([]),
|
findByStatuses: jest.fn().mockResolvedValue([]),
|
||||||
|
findBookingsWithUnreviewedDocuments: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Set<string>()),
|
||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
@@ -115,6 +118,7 @@ function makeService(overrides?: {
|
|||||||
} as never, // transit agents
|
} as never, // transit agents
|
||||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type RiskAssignmentRecord,
|
type RiskAssignmentRecord,
|
||||||
} from './entities/clearance-milestone.entity';
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
@@ -35,6 +35,13 @@ import { ContractsRepository } from './contracts.repository';
|
|||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildClearanceDocHistory,
|
||||||
|
type ClearanceDocEvent,
|
||||||
|
} from '../bookings/clearance-doc-history.util';
|
||||||
|
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
||||||
|
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
export interface BookingClearanceView {
|
export interface BookingClearanceView {
|
||||||
@@ -52,8 +59,19 @@ export interface BookingClearanceView {
|
|||||||
file: { id: string; name: string; url: string } | null;
|
file: { id: string; name: string; url: string } | null;
|
||||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
uploadedAt: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewedByName: string | null;
|
||||||
|
history: ClearanceDocEvent[];
|
||||||
}>;
|
}>;
|
||||||
allApproved: boolean;
|
allApproved: boolean;
|
||||||
|
documentsOpen: boolean;
|
||||||
|
docRequests: Array<{
|
||||||
|
id: string;
|
||||||
|
note: string;
|
||||||
|
byName: string | null;
|
||||||
|
at: string;
|
||||||
|
}>;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
milestones?: Array<{
|
milestones?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -160,6 +178,7 @@ export class BookingClearanceService {
|
|||||||
private readonly transitAgentsService: TransitAgentsService,
|
private readonly transitAgentsService: TransitAgentsService,
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly yardScope: YardScopeService,
|
private readonly yardScope: YardScopeService,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||||
@@ -185,6 +204,23 @@ export class BookingClearanceService {
|
|||||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||||
|
const allVersions = await this.filesService.findAllVersionsByResource(
|
||||||
|
bookingId,
|
||||||
|
'bookings',
|
||||||
|
);
|
||||||
|
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
||||||
|
bookingId,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||||
|
bookingId,
|
||||||
|
'ADDITIONAL_DOC_REQUEST',
|
||||||
|
);
|
||||||
|
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||||
|
...reviews.map((r) => r.reviewedByStaffId),
|
||||||
|
...queryNotes.map((n) => n.authorId),
|
||||||
|
...docRequestNotes.map((n) => n.authorId),
|
||||||
|
]);
|
||||||
|
|
||||||
const documents: BookingClearanceView['documents'] = [];
|
const documents: BookingClearanceView['documents'] = [];
|
||||||
|
|
||||||
@@ -208,6 +244,18 @@ export class BookingClearanceService {
|
|||||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
|
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByName: review?.reviewedByStaffId
|
||||||
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
history: buildClearanceDocHistory({
|
||||||
|
fileKey: field.fileKey,
|
||||||
|
allVersions,
|
||||||
|
queryNotes,
|
||||||
|
review,
|
||||||
|
names: reviewerNames,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -220,13 +268,27 @@ export class BookingClearanceService {
|
|||||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
documents.push({
|
documents.push({
|
||||||
fileKey: f.code,
|
fileKey: f.code,
|
||||||
label: f.name,
|
// What the customer called it, falling back to the filename for rows
|
||||||
|
// uploaded before the name was carried through.
|
||||||
|
label: f.title || adHocLabel(f.code) || f.name,
|
||||||
required: false,
|
required: false,
|
||||||
uploadedBy: 'customer',
|
uploadedBy: 'customer',
|
||||||
settingCode: 'custom',
|
settingCode: 'custom',
|
||||||
file: { id: f.id, name: f.name, url: f.url },
|
file: { id: f.id, name: f.name, url: f.url },
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
|
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByName: review?.reviewedByStaffId
|
||||||
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
history: buildClearanceDocHistory({
|
||||||
|
fileKey: f.code,
|
||||||
|
allVersions,
|
||||||
|
queryNotes,
|
||||||
|
review,
|
||||||
|
names: reviewerNames,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +347,8 @@ export class BookingClearanceService {
|
|||||||
} catch {
|
} catch {
|
||||||
train = null;
|
train = null;
|
||||||
}
|
}
|
||||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
// Removed from the clearance flow — see gl-operations.service.
|
||||||
|
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
milestones.find((m) => m.milestoneCode === code);
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||||
@@ -307,6 +370,13 @@ export class BookingClearanceService {
|
|||||||
outputCode,
|
outputCode,
|
||||||
documents,
|
documents,
|
||||||
allApproved,
|
allApproved,
|
||||||
|
documentsOpen: clearanceDocumentsOpen(booking),
|
||||||
|
docRequests: docRequestNotes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
note: n.note,
|
||||||
|
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||||
|
at: n.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
phase,
|
phase,
|
||||||
milestones: milestones.map((m) => ({
|
milestones: milestones.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -479,6 +549,7 @@ export class BookingClearanceService {
|
|||||||
async requestTransitAssignee(
|
async requestTransitAssignee(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
note: string | undefined,
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
|
|
||||||
@@ -486,6 +557,13 @@ export class BookingClearanceService {
|
|||||||
transitAssigneeRequestedAt: new Date(),
|
transitAssigneeRequestedAt: new Date(),
|
||||||
transitAssigneeRequestNote: note?.trim() || null,
|
transitAssigneeRequestNote: note?.trim() || null,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'TRANSIT_ASSIGNEE_REQUESTED',
|
||||||
|
label: 'Requested a transit assignee from GL Djibouti',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { note: note?.trim() || null },
|
||||||
|
});
|
||||||
|
|
||||||
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
|
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -497,7 +575,11 @@ export class BookingClearanceService {
|
|||||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||||
* the name (reassignment) and re-notifies.
|
* the name (reassignment) and re-notifies.
|
||||||
*/
|
*/
|
||||||
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
|
async assignTransitAssignee(
|
||||||
|
bookingId: string,
|
||||||
|
transitAgentId: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (!booking.transitAssigneeRequestedAt) {
|
if (!booking.transitAssigneeRequestedAt) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -511,6 +593,13 @@ export class BookingClearanceService {
|
|||||||
transitAssigneeName: agent.name,
|
transitAssigneeName: agent.name,
|
||||||
transitAssigneeAssignedAt: new Date(),
|
transitAssigneeAssignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
|
||||||
|
label: `Assigned transit officer "${agent.name}"`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { transitAgentId, agentName: agent.name, previous },
|
||||||
|
});
|
||||||
|
|
||||||
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
|
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -563,6 +652,13 @@ export class BookingClearanceService {
|
|||||||
? ContractDocPhase.GlEtPostClearance
|
? ContractDocPhase.GlEtPostClearance
|
||||||
: ContractDocPhase.CustomerDuty,
|
: ContractDocPhase.CustomerDuty,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DECLARATION_UPLOADED',
|
||||||
|
label: `Uploaded customs declaration (${files.length} file(s))`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { fileNames: files.map((f) => f.originalname) },
|
||||||
|
});
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -616,6 +712,19 @@ export class BookingClearanceService {
|
|||||||
);
|
);
|
||||||
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
||||||
}
|
}
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DUTY_ADVISED',
|
||||||
|
label: dto.dutyRequired
|
||||||
|
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
|
||||||
|
: 'Advised that no duty/tax applies',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: {
|
||||||
|
dutyRequired: dto.dutyRequired,
|
||||||
|
amount: dto.amount ?? null,
|
||||||
|
currency: dto.currency ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -663,6 +772,14 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DRAFT_DECLARATION_SENT',
|
||||||
|
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await this.bookingsService.findById(bookingId);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.draftDeclarationReady(updated, price, currency);
|
this.notifier.draftDeclarationReady(updated, price, currency);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -672,7 +789,7 @@ export class BookingClearanceService {
|
|||||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||||
* real customs declaration.
|
* real customs declaration.
|
||||||
*/
|
*/
|
||||||
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
|
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||||
@@ -684,6 +801,13 @@ export class BookingClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DRAFT_DECLARATION_ACCEPTED',
|
||||||
|
label: 'Customer accepted the draft customs declaration',
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,12 +857,25 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
|
||||||
|
label: 'Customer requested a change to the draft declaration',
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { note: note.trim() },
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await this.bookingsService.findById(bookingId);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
async uploadDutySlip(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
||||||
@@ -760,6 +897,15 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DUTY_SLIP_UPLOADED',
|
||||||
|
label: 'Customer uploaded the duty/tax payment slip',
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { fileName: file.originalname },
|
||||||
|
});
|
||||||
|
|
||||||
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -792,11 +938,18 @@ export class BookingClearanceService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'TRANSIT_PERMIT_UPLOADED',
|
||||||
|
label: `Uploaded transit permit (${files.length} file(s))`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { fileNames: files.map((f) => f.originalname) },
|
||||||
|
});
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async finalizePreClearance(bookingId: string): Promise<Booking> {
|
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
||||||
@@ -816,6 +969,12 @@ export class BookingClearanceService {
|
|||||||
preClearanceFinalizedAt: new Date(),
|
preClearanceFinalizedAt: new Date(),
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'PRE_CLEARANCE_FINALIZED',
|
||||||
|
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
@@ -849,6 +1008,17 @@ export class BookingClearanceService {
|
|||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
doCollectedDate,
|
doCollectedDate,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'DELIVERY_ORDER_UPLOADED',
|
||||||
|
label: 'Uploaded Delivery Order',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: {
|
||||||
|
vesselArrivalDate: vesselArrivalDate ?? null,
|
||||||
|
doCollectedDate: doCollectedDate ?? null,
|
||||||
|
fileNames: (files ?? []).map((f) => f.originalname),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (booking.preClearanceFinalizedAt) {
|
if (booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||||
@@ -906,6 +1076,16 @@ export class BookingClearanceService {
|
|||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
roAmendmentRequestedAt: null,
|
roAmendmentRequestedAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'RELEASE_ORDER_UPLOADED',
|
||||||
|
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: {
|
||||||
|
vesselDepartureDate,
|
||||||
|
fileNames: (files ?? []).map((f) => f.originalname),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (leadDays < minDays) {
|
if (leadDays < minDays) {
|
||||||
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
||||||
@@ -965,6 +1145,13 @@ export class BookingClearanceService {
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'RO_AMENDMENT_REQUESTED',
|
||||||
|
label: 'Requested a port amendment on the Release Order',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
metadata: { note: reason },
|
||||||
|
});
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -980,6 +1167,12 @@ export class BookingClearanceService {
|
|||||||
'EXPORT_RELEASED',
|
'EXPORT_RELEASED',
|
||||||
);
|
);
|
||||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'EXPORT_RELEASE_CONFIRMED',
|
||||||
|
label: 'Confirmed export release',
|
||||||
|
actorId: userId ?? null,
|
||||||
|
});
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,8 +1184,30 @@ export class BookingClearanceService {
|
|||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!this.isPhasedCustomsBooking(b)) continue;
|
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
if (!belongsOnEtClearanceQueue(milestones)) continue;
|
||||||
|
// Surfaced on the queue row: every required document is approved even
|
||||||
|
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
|
||||||
|
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
|
||||||
|
milestones.some(
|
||||||
|
(m) =>
|
||||||
|
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
|
||||||
|
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
|
||||||
|
);
|
||||||
|
filtered.push(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A document added after clearance was finalized lands as PENDING without
|
||||||
|
// moving the booking's status — the row would otherwise still read
|
||||||
|
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
|
||||||
|
// tracked by no milestone, so this reads the review rows directly.
|
||||||
|
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
|
||||||
|
filtered.map((b) => b.id),
|
||||||
|
);
|
||||||
|
for (const b of filtered) {
|
||||||
|
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
|
||||||
|
.hasDocumentsAwaitingReview = pending.has(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await this.attachContractSummary(filtered);
|
const rows = await this.attachContractSummary(filtered);
|
||||||
return this.narrowToYardScope(rows, user);
|
return this.narrowToYardScope(rows, user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
let train: ClearanceTrainState | null = null;
|
let train: ClearanceTrainState | null = null;
|
||||||
let bookingMilestones: ClearanceMilestone[] = [];
|
let bookingMilestones: ClearanceMilestone[] = [];
|
||||||
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
// Removed from the clearance flow — see gl-operations.service. Kept in the
|
||||||
|
// payload (always null) so existing consumers keep type-checking.
|
||||||
|
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||||
if (cycle?.bookingId) {
|
if (cycle?.bookingId) {
|
||||||
try {
|
try {
|
||||||
train = await this.glOperationsService.trainState(cycle.bookingId);
|
train = await this.glOperationsService.trainState(cycle.bookingId);
|
||||||
@@ -340,7 +342,6 @@ export class ContractClearanceService {
|
|||||||
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||||
cycle.bookingId,
|
cycle.bookingId,
|
||||||
);
|
);
|
||||||
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
|
|
||||||
}
|
}
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
|
|||||||
@@ -376,12 +376,21 @@ export class ContractPricingService {
|
|||||||
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||||
// A customs contract may not proceed without the fee(s) configured.
|
// A customs contract may not proceed without the fee(s) configured.
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
|
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||||
|
// snapshot codes carry the same prefix so booking pricing finds them.
|
||||||
|
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
|
||||||
|
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||||
|
: 'CUSTOMS_CLEARANCE';
|
||||||
|
const customsLabel =
|
||||||
|
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||||
|
? 'Ethiopian customs clearance service'
|
||||||
|
: 'Customs clearance service';
|
||||||
// Strict, no route-less fallback.
|
// Strict, no route-less fallback.
|
||||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||||
const onLeg = route
|
const onLeg = route
|
||||||
? liveRates.filter(
|
? liveRates.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
r.rateType === customsType &&
|
||||||
r.currency === 'USD' &&
|
r.currency === 'USD' &&
|
||||||
r.tradeDirection === contract.tradeDirection &&
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
r.originYardId === route.originYardId &&
|
r.originYardId === route.originYardId &&
|
||||||
@@ -406,14 +415,14 @@ export class ContractPricingService {
|
|||||||
);
|
);
|
||||||
if (!rate || Number(rate.rateValue) <= 0) {
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
throw new UnprocessableEntityException(
|
throw new UnprocessableEntityException(
|
||||||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
// Distinct code per size so the frozen snapshots don't collide —
|
// Distinct code per size so the frozen snapshots don't collide —
|
||||||
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
// booking pricing looks each size up by <customsType>_<FT>FT.
|
||||||
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
code: `${customsType}_${sizeFt}FT`,
|
||||||
label: `Customs clearance service (${size})`,
|
label: `${customsLabel} (${size})`,
|
||||||
unit: toContractUnit(rate.rateUnit),
|
unit: toContractUnit(rate.rateUnit),
|
||||||
unitPrice: convert(Number(rate.rateValue)),
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
@@ -432,12 +441,12 @@ export class ContractPricingService {
|
|||||||
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
if (!rate || Number(rate.rateValue) <= 0) {
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
throw new UnprocessableEntityException(
|
throw new UnprocessableEntityException(
|
||||||
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
code: customsType,
|
||||||
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||||
unit: toContractUnit(rate.rateUnit),
|
unit: toContractUnit(rate.rateUnit),
|
||||||
unitPrice: convert(Number(rate.rateValue)),
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
cargoTypeCode: scope?.cargoType?.code ?? null,
|
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
|
|||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the booking-contract view reads the global stamp live, the contracts
|
* The staff signature seals with the ONE global stamp by REFERENCE: the
|
||||||
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
|
* signature row stores the current global stampFileId instead of re-uploading
|
||||||
* company stamp can never restamp an already-executed contract. These specs
|
* a copy per contract. That id stays valid after the stamp is replaced
|
||||||
* pin the sourcing split: EDR always seals with the global stamp and staff
|
* (StampSettingsService never deletes retired stamp files), so each contract
|
||||||
* never supply one, while the customer must upload their own.
|
* keeps the exact seal it was signed with. These specs pin the sourcing
|
||||||
|
* split: EDR always seals with the global stamp and staff never supply one,
|
||||||
|
* while the customer must upload their own.
|
||||||
*/
|
*/
|
||||||
describe('applySignature stamp sourcing', () => {
|
describe('applySignature stamp sourcing', () => {
|
||||||
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||||
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
||||||
|
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
|
||||||
|
|
||||||
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
|
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
|
||||||
const uploads: Array<{ code: string; image: string }> = [];
|
const uploads: Array<{ code: string; image: string }> = [];
|
||||||
const saved: unknown[] = [];
|
const saved: unknown[] = [];
|
||||||
const service = Object.create(
|
const service = Object.create(
|
||||||
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
|
|||||||
Object.assign(service, {
|
Object.assign(service, {
|
||||||
logger: { warn: jest.fn(), log: jest.fn() },
|
logger: { warn: jest.fn(), log: jest.fn() },
|
||||||
stampSettings: {
|
stampSettings: {
|
||||||
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
|
get: jest.fn().mockResolvedValue({
|
||||||
|
id: 's-1',
|
||||||
|
stampFileId: globalStampFileId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
contractsRepository: {
|
contractsRepository: {
|
||||||
saveSignature: jest.fn((row: unknown) => {
|
saveSignature: jest.fn((row: unknown) => {
|
||||||
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
|
|||||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||||
};
|
};
|
||||||
|
|
||||||
it('seals the EDR side with the global stamp', async () => {
|
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
|
||||||
const { service, uploads, saved } = build();
|
const { service, uploads, saved } = build();
|
||||||
|
|
||||||
await apply(service, staffDto);
|
await apply(service, staffDto);
|
||||||
|
|
||||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
|
||||||
expect(saved[0]).toEqual(
|
expect(saved[0]).toEqual(
|
||||||
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
|
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a stamp a staff client tries to supply', async () => {
|
it('ignores a stamp a staff client tries to supply', async () => {
|
||||||
const { service, uploads } = build();
|
const { service, uploads, saved } = build();
|
||||||
|
|
||||||
await apply(service, {
|
await apply(service, {
|
||||||
...staffDto,
|
...staffDto,
|
||||||
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
|
||||||
expect(uploads.map((u) => u.image)).not.toContain(
|
expect(uploads.map((u) => u.image)).not.toContain(
|
||||||
'data:image/png;base64,SEFDSw==',
|
'data:image/png;base64,SEFDSw==',
|
||||||
);
|
);
|
||||||
|
expect(saved[0]).toEqual(
|
||||||
|
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failing loudly matters here: getStampImageUrl degrades to null when the
|
* Failing loudly matters here: silently executing an unsealed contract
|
||||||
* stamp cannot be inlined, and silently executing an unsealed contract would
|
* would be worse than refusing to counter-sign.
|
||||||
* be worse than refusing to counter-sign.
|
|
||||||
*/
|
*/
|
||||||
it('refuses to counter-sign when no global stamp is configured', async () => {
|
it('refuses to counter-sign when no global stamp is configured', async () => {
|
||||||
const { service, saved } = build(null);
|
const { service, saved } = build(null);
|
||||||
|
|||||||
@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot whichever stamp applies onto the signature row rather than
|
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
|
||||||
// referencing the global one, so replacing the company stamp later can
|
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
|
||||||
// never restamp an already-executed contract.
|
// never delete a replaced stamp file: the referenced id keeps rendering
|
||||||
let stampImageBase64 = dto.stampImageBase64 ?? null;
|
// the exact seal that was current at signing, even after the global stamp
|
||||||
|
// is later replaced. The customer's stamp is their own upload and is still
|
||||||
|
// stored per contract.
|
||||||
|
let stampFileId: string | null = null;
|
||||||
if (role === 'STAFF') {
|
if (role === 'STAFF') {
|
||||||
stampImageBase64 = await this.stampSettings.getStampImageUrl();
|
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
|
||||||
if (!stampImageBase64) {
|
if (!stampFileId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (dto.stampImageBase64) {
|
||||||
|
const stampRecord = await this.uploadSignatureAsset(
|
||||||
|
contract,
|
||||||
|
`stamp_${role.toLowerCase()}`,
|
||||||
|
dto.stampImageBase64,
|
||||||
|
);
|
||||||
|
stampFileId = stampRecord.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileRecord = await this.uploadSignatureAsset(
|
const fileRecord = await this.uploadSignatureAsset(
|
||||||
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
|
|||||||
`signature_${role.toLowerCase()}`,
|
`signature_${role.toLowerCase()}`,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
);
|
);
|
||||||
const stampRecord = stampImageBase64
|
|
||||||
? await this.uploadSignatureAsset(
|
|
||||||
contract,
|
|
||||||
`stamp_${role.toLowerCase()}`,
|
|
||||||
stampImageBase64,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await this.contractsRepository.saveSignature({
|
await this.contractsRepository.saveSignature({
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
|
|||||||
signerDisplayName,
|
signerDisplayName,
|
||||||
signedAt: new Date(),
|
signedAt: new Date(),
|
||||||
signatureFileId: fileRecord.id,
|
signatureFileId: fileRecord.id,
|
||||||
stampFileId: stampRecord?.id ?? null,
|
stampFileId,
|
||||||
consentText: dto.consentText ?? null,
|
consentText: dto.consentText ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ export interface ContractListFilterOptions {
|
|||||||
hasClearanceDocuments?: boolean;
|
hasClearanceDocuments?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
originYardId?: string;
|
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||||
destinationYardId?: string;
|
originYardId?: string[];
|
||||||
|
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||||
|
destinationYardId?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
// Routes are one-to-many (a contract can list several lanes), so origin
|
// Routes are one-to-many (a contract can list several lanes), so origin
|
||||||
// and destination each need their own EXISTS — a plain join would
|
// and destination each need their own EXISTS — a plain join would
|
||||||
// duplicate the contract row per matching route.
|
// duplicate the contract row per matching route.
|
||||||
if (omit !== 'originYardId' && options.originYardId) {
|
// Each end is an OR-list, the two ends AND together. Note this still means
|
||||||
|
// "has a route from one of these origins" AND "has a route to one of these
|
||||||
|
// destinations" — not necessarily the SAME route, which is what the two
|
||||||
|
// separate EXISTS have always meant and what the filter bar's two
|
||||||
|
// independent pickers describe.
|
||||||
|
if (omit !== 'originYardId' && options.originYardId?.length) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
||||||
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
||||||
'AND cr_o.origin_yard_id = :originYardId)',
|
'AND cr_o.origin_yard_id IN (:...originYardIds))',
|
||||||
{ originYardId: options.originYardId },
|
{ originYardIds: options.originYardId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
if (omit !== 'destinationYardId' && options.destinationYardId?.length) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
||||||
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
||||||
'AND cr_d.destination_yard_id = :destinationYardId)',
|
'AND cr_d.destination_yard_id IN (:...destinationYardIds))',
|
||||||
{ destinationYardId: options.destinationYardId },
|
{ destinationYardIds: options.destinationYardId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,14 @@ export class ContractsService {
|
|||||||
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The service type a contract is sold under (null when the id is unknown). */
|
||||||
|
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
|
||||||
|
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a service type bundles customs clearance. */
|
/** Whether a service type bundles customs clearance. */
|
||||||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||||
const serviceType = await this.dataSource
|
const serviceType = await this.resolveServiceType(serviceTypeId);
|
||||||
.getRepository(ServiceType)
|
|
||||||
.findOne({ where: { id: serviceTypeId } });
|
|
||||||
return serviceType?.includesCustoms ?? false;
|
return serviceType?.includesCustoms ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +327,8 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customs clearing is owned by the service type, not the customer.
|
// Customs clearing is owned by the service type, not the customer.
|
||||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
|
||||||
|
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||||||
// Intercity never crosses a border, so a customs-including service type is
|
// Intercity never crosses a border, so a customs-including service type is
|
||||||
// a contradiction — the wizard hides them, the API enforces it.
|
// a contradiction — the wizard hides them, the API enforces it.
|
||||||
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||||||
@@ -344,6 +348,8 @@ export class ContractsService {
|
|||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
paymentCurrency: 'USD',
|
paymentCurrency: 'USD',
|
||||||
customsClearingEnabled: includesCustoms,
|
customsClearingEnabled: includesCustoms,
|
||||||
|
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
|
||||||
|
serviceType,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
equipmentReturn: dto.equipmentReturn ?? null,
|
equipmentReturn: dto.equipmentReturn ?? null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||||
|
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
@@ -62,20 +63,22 @@ export class FilterContractDto {
|
|||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
description:
|
||||||
description: 'Only contracts with a route starting at this yard.',
|
'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
originYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
originYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
description:
|
||||||
description: 'Only contracts with a route ending at this yard.',
|
'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IdListParam()
|
||||||
destinationYardId?: string;
|
@IsUUID(undefined, { each: true })
|
||||||
|
destinationYardId?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -779,7 +779,15 @@ export class GlOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
/**
|
||||||
|
* Final-invoice state joined with its document + slip files.
|
||||||
|
*
|
||||||
|
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
|
||||||
|
* longer part of the export process, is not rendered on either desk or the
|
||||||
|
* portal, and never gated anything downstream. The endpoints and this reader
|
||||||
|
* stay so already-issued invoices remain resolvable; nothing calls it from a
|
||||||
|
* clearance view any more.
|
||||||
|
*/
|
||||||
async finalInvoiceSummary(
|
async finalInvoiceSummary(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { ArrayMinSize, IsArray, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */
|
||||||
|
export class BulkRegisterEimsInvoiceDto {
|
||||||
|
@ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsUUID("4", { each: true })
|
||||||
|
invoiceIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
|
|
||||||
|
const SYSTEM_NUMBER = "B0360154BA";
|
||||||
|
const INVOICE_A = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const INVOICE_B = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5";
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_A,
|
||||||
|
invoiceNumber: "INV-20260807-00001",
|
||||||
|
currency: "ETB",
|
||||||
|
companyId: "company-1",
|
||||||
|
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||||
|
totalAmount: "10000.00",
|
||||||
|
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsIrn: null,
|
||||||
|
eimsDocumentType: "INV",
|
||||||
|
eimsBulkConversationId: null,
|
||||||
|
company: {
|
||||||
|
name: "ABC Trading PLC",
|
||||||
|
tin: "0999930000",
|
||||||
|
vatNumber: "123475885858",
|
||||||
|
phone: "0912345678",
|
||||||
|
region: "13",
|
||||||
|
zone: "SHA",
|
||||||
|
woreda: "574",
|
||||||
|
kebele: "03",
|
||||||
|
houseNo: "NEW",
|
||||||
|
country: "Ethiopia",
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
const LINES = (id: string) => [
|
||||||
|
{
|
||||||
|
invoiceId: id,
|
||||||
|
chargeType: "RAIL_FREIGHT",
|
||||||
|
description: "Addis to Djibouti",
|
||||||
|
quantity: "1.00",
|
||||||
|
unitRate: "10000.00",
|
||||||
|
amount: "10000.00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** In-memory stand-in covering the query/manager surface this service actually calls. */
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
state: EimsSystemState;
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[], state: Partial<EimsSystemState> = {}) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
this.state = {
|
||||||
|
id: "state-1",
|
||||||
|
systemNumber: SYSTEM_NUMBER,
|
||||||
|
nextInvoiceCounter: 1,
|
||||||
|
nextDocumentNumber: 1,
|
||||||
|
previousIrn: null,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
|
inFlightConversationId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
...state,
|
||||||
|
} as EimsSystemState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(entity: Invoice | EimsSystemState, where: Record<string, unknown>): boolean {
|
||||||
|
return Object.entries(where).every(([key, value]) => (entity as never)[key] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private queryBuilder(entityCtor: unknown) {
|
||||||
|
let where: Record<string, unknown> = {};
|
||||||
|
const builder = {
|
||||||
|
setLock: () => builder,
|
||||||
|
where: (_clause: string, params: Record<string, unknown>) => {
|
||||||
|
where = { ...where, ...this.normalizeParams(params) };
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
andWhere: (_clause: string, params: Record<string, unknown>) => {
|
||||||
|
where = { ...where, ...this.normalizeParams(params) };
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
getOne: async () => this.find(entityCtor, where)[0] ?? null,
|
||||||
|
getMany: async () => this.find(entityCtor, where),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
// Test-only mapping from the SQL param names used in the service's own queries to entity fields.
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
invoiceId: "id",
|
||||||
|
systemNumber: "systemNumber",
|
||||||
|
id: "eimsBulkConversationId",
|
||||||
|
};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private find(entityCtor: unknown, where: Record<string, unknown>): Array<Invoice | EimsSystemState> {
|
||||||
|
const isState = entityCtor === EimsSystemState;
|
||||||
|
const pool: Array<Invoice | EimsSystemState> = isState ? [this.state] : [...this.invoices.values()];
|
||||||
|
return pool.filter((e) => this.matches(e, where));
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor),
|
||||||
|
query: async () => [],
|
||||||
|
findOne: async (entityCtor: unknown, options: { where: Record<string, unknown> }) =>
|
||||||
|
this.find(entityCtor, options.where)[0] ?? null,
|
||||||
|
update: async (entityCtor: unknown, idOrWhere: string | Record<string, unknown>, patch: Record<string, unknown>) => {
|
||||||
|
const targets =
|
||||||
|
typeof idOrWhere === "string"
|
||||||
|
? this.find(entityCtor, { id: idOrWhere })
|
||||||
|
: this.find(entityCtor, idOrWhere);
|
||||||
|
for (const t of targets) Object.assign(t, patch);
|
||||||
|
return { affected: targets.length };
|
||||||
|
},
|
||||||
|
getRepository: (entityCtor: unknown) => ({
|
||||||
|
findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
// Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this
|
||||||
|
// one entry point in the real DataSource.
|
||||||
|
query: async (sql: string) => {
|
||||||
|
if (sql.includes("invoice_lines")) {
|
||||||
|
return [...this.invoices.keys()].flatMap((id) => LINES(id));
|
||||||
|
}
|
||||||
|
return this.companyContact ? [this.companyContact] : [];
|
||||||
|
},
|
||||||
|
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
|
||||||
|
getRepository: () => ({
|
||||||
|
find: async (options: { where: { id: { value: string[] } } }) => {
|
||||||
|
const ids = options.where.id.value ?? [];
|
||||||
|
return ids.map((id: string) => this.invoices.get(id)).filter(Boolean);
|
||||||
|
},
|
||||||
|
createQueryBuilder: (alias: string) => {
|
||||||
|
void alias;
|
||||||
|
return this.queryBuilder(Invoice);
|
||||||
|
},
|
||||||
|
count: async (options: { where: Record<string, unknown> }) => this.find(Invoice, options.where).length,
|
||||||
|
}),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
|
||||||
|
new EimsBulkRegistrationService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService,
|
||||||
|
{ postSigned } as unknown as EimsClientService,
|
||||||
|
{ getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
{ getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 });
|
||||||
|
|
||||||
|
describe("EimsBulkRegistrationService.registerBulk", () => {
|
||||||
|
it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => {
|
||||||
|
const db = new FakeDb(
|
||||||
|
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||||
|
{ nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" },
|
||||||
|
);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||||
|
|
||||||
|
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] });
|
||||||
|
const [, request] = postSigned.mock.calls[0];
|
||||||
|
expect(request).toHaveLength(2);
|
||||||
|
expect(request[0].SourceSystem.InvoiceCounter).toBe(5);
|
||||||
|
expect(request[0].DocumentDetails.DocumentNumber).toBe("5");
|
||||||
|
expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn");
|
||||||
|
expect(request[1].SourceSystem.InvoiceCounter).toBe(6);
|
||||||
|
// Only the first item in a bulk batch chains — the rest have no IRN to reference yet.
|
||||||
|
expect(request[1].ReferenceDetails.PreviousIrn).toBe("");
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||||
|
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||||
|
expect(db.state.nextInvoiceCounter).toBe(7);
|
||||||
|
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an already-registered invoice, without consuming a counter for it", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }),
|
||||||
|
invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||||
|
|
||||||
|
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||||
|
|
||||||
|
expect(result.alreadyRegistered).toEqual([INVOICE_A]);
|
||||||
|
expect(result.accepted).toEqual([INVOICE_B]);
|
||||||
|
const [, request] = postSigned.mock.calls[0];
|
||||||
|
expect(request).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.state.inFlightConversationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when a single-invoice submission is already in flight", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" });
|
||||||
|
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when another bulk batch is already in flight", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" });
|
||||||
|
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => {
|
||||||
|
const db = new FakeDb(
|
||||||
|
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||||
|
{ nextInvoiceCounter: 5, nextDocumentNumber: 5 },
|
||||||
|
);
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400));
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
|
||||||
|
expect(db.state.nextInvoiceCounter).toBe(5);
|
||||||
|
expect(db.state.nextDocumentNumber).toBe(5);
|
||||||
|
expect(db.state.inFlightConversationId).toBeNull();
|
||||||
|
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||||
|
expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ambiguous failure blocks the system number and leaves counters consumed", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 });
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out"));
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
|
||||||
|
expect(db.state.nextInvoiceCounter).toBe(6);
|
||||||
|
expect(db.state.blockedReason).toMatch(/never acknowledged/);
|
||||||
|
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty invoice list", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsBulkRegistrationService.handleBulkCallback", () => {
|
||||||
|
const submittingRow = (over: Partial<Invoice>) =>
|
||||||
|
invoiceRow({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||||
|
eimsBulkConversationId: CONVERSATION_ID,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => {
|
||||||
|
const db = new FakeDb(
|
||||||
|
[
|
||||||
|
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||||
|
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||||
|
],
|
||||||
|
{ inFlightConversationId: CONVERSATION_ID },
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||||
|
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||||
|
{ ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" },
|
||||||
|
{ conversionId: CONVERSATION_ID },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" });
|
||||||
|
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed });
|
||||||
|
expect(db.state.previousIrn).toBe("irn-a");
|
||||||
|
expect(db.state.inFlightConversationId).toBeNull();
|
||||||
|
expect(results).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }),
|
||||||
|
expect.objectContaining({ invoiceId: INVOICE_B, success: false }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a callback for an unknown or already-settled conversation", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||||
|
{ irn: "irn-x", status: "A", documentNumber: "1" },
|
||||||
|
{ conversionId: "no-such-conversation" },
|
||||||
|
]);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => {
|
||||||
|
const db = new FakeDb(
|
||||||
|
[
|
||||||
|
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||||
|
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||||
|
],
|
||||||
|
{ inFlightConversationId: CONVERSATION_ID },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callback only reports on one of the two invoices in this batch.
|
||||||
|
await build(db, jest.fn()).handleBulkCallback([
|
||||||
|
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||||
|
{ conversionId: CONVERSATION_ID },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the current state without re-settling an invoice that already resolved", async () => {
|
||||||
|
const db = new FakeDb(
|
||||||
|
[
|
||||||
|
invoiceRow({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: "irn-a",
|
||||||
|
eimsDocumentNumber: "1",
|
||||||
|
eimsBulkConversationId: CONVERSATION_ID,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ inFlightConversationId: CONVERSATION_ID },
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||||
|
{ irn: "irn-a", status: "A", documentNumber: "1" },
|
||||||
|
{ conversionId: CONVERSATION_ID },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
|
import {
|
||||||
|
EimsBulkCallbackItem,
|
||||||
|
EimsBulkRegisterAcceptedResponse,
|
||||||
|
EimsBulkRegisterItemResult,
|
||||||
|
EimsBulkRegisterRequest,
|
||||||
|
EimsInvoiceError,
|
||||||
|
EimsInvoiceStatus,
|
||||||
|
} from "./eims-registration.types";
|
||||||
|
|
||||||
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||||
|
|
||||||
|
interface BulkReservation {
|
||||||
|
stateId: string;
|
||||||
|
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||||
|
documentType: EimsDocumentType;
|
||||||
|
relatedDocument: string | null;
|
||||||
|
invoiceCounter: number;
|
||||||
|
documentNumber: string;
|
||||||
|
previousIrn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`.
|
||||||
|
*
|
||||||
|
* Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`:
|
||||||
|
* that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk
|
||||||
|
* does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice
|
||||||
|
* results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later
|
||||||
|
* as a POST to a webhook MoR was configured with out of band. That means this service has two
|
||||||
|
* halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback`
|
||||||
|
* — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles.
|
||||||
|
*
|
||||||
|
* Reservation follows the same durable-reservation doctrine as the single-invoice service (counters
|
||||||
|
* consumed and the holder recorded, committed, before the HTTP call leaves the process), extended
|
||||||
|
* to a contiguous block of N counters instead of one. The "something is in flight" marker is
|
||||||
|
* `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding,
|
||||||
|
* not one invoice — and the two markers block each other: a single registration cannot start while
|
||||||
|
* a bulk batch is pending, and vice versa, because they share the same counter sequence.
|
||||||
|
*
|
||||||
|
* The conversation id is not known until MoR's 202 response arrives, so reservation stamps a
|
||||||
|
* locally-generated placeholder token first (same "commit the reservation before the network call"
|
||||||
|
* reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only
|
||||||
|
* value the webhook callback can actually use to find this batch again.
|
||||||
|
*
|
||||||
|
* Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as
|
||||||
|
* one envelope, the way single `/v1/register` was confirmed live to need despite the collection's
|
||||||
|
* raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a
|
||||||
|
* blind guess, but it has not itself been exercised against the real gateway.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsBulkRegistrationService {
|
||||||
|
private readonly logger = new Logger(EimsBulkRegistrationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly sellerCache: EimsSellerCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call.
|
||||||
|
* An invoice that already has an IRN is silently skipped (idempotent, matching single register);
|
||||||
|
* everything else must pass the same DEB/CRE precondition single register checks, or the whole
|
||||||
|
* call is refused before anything is reserved.
|
||||||
|
*/
|
||||||
|
async registerBulk(
|
||||||
|
invoiceIds: string[],
|
||||||
|
): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> {
|
||||||
|
const cfg = this.cfg;
|
||||||
|
assertEimsInvoiceConfig(cfg);
|
||||||
|
|
||||||
|
const ids = [...new Set(invoiceIds)];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await this.loadInvoicesForMapping(ids);
|
||||||
|
const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id);
|
||||||
|
const pending = invoices.filter((inv) => !inv.eimsIrn);
|
||||||
|
|
||||||
|
// Same DEB/CRE precondition as single register, checked for every pending invoice before any
|
||||||
|
// counter is touched: a bad member must fail the whole batch, not surface mid-submission.
|
||||||
|
const prepared = pending.map((invoice) => {
|
||||||
|
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
||||||
|
let relatedDocument: string | null = null;
|
||||||
|
if (documentType !== "INV") {
|
||||||
|
if (!invoice.relatedInvoice) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!invoice.relatedInvoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||||
|
}
|
||||||
|
return { invoice, documentType, relatedDocument };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prepared.length === 0) {
|
||||||
|
return { conversationId: null, accepted: [], alreadyRegistered };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
const placeholder = `local:${randomUUID()}`;
|
||||||
|
const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder);
|
||||||
|
|
||||||
|
let conversationId: string;
|
||||||
|
try {
|
||||||
|
const requests: EimsBulkRegisterRequest = reservations.map((r) =>
|
||||||
|
toEimsInvoice(
|
||||||
|
r.invoice,
|
||||||
|
this.sellerCache.getSellerDetails(cfg),
|
||||||
|
buildEimsContext(cfg, {
|
||||||
|
documentNumber: r.documentNumber,
|
||||||
|
invoiceCounter: r.invoiceCounter,
|
||||||
|
previousIrn: r.previousIrn,
|
||||||
|
session,
|
||||||
|
documentType: r.documentType,
|
||||||
|
reason: r.invoice.eimsReason,
|
||||||
|
relatedDocument: r.relatedDocument,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const response = await this.client.postSigned<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
|
||||||
|
"/v1/bulkRegister",
|
||||||
|
requests,
|
||||||
|
);
|
||||||
|
if (!response?.conversationId) {
|
||||||
|
throw new EimsApiException(
|
||||||
|
"SCHEMA_VALIDATION",
|
||||||
|
"EIMS bulkRegister returned no conversationId",
|
||||||
|
response?.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
conversationId = response.conversationId;
|
||||||
|
} catch (err) {
|
||||||
|
await this.settleBulkFailure(reservations, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimConversationId(placeholder, conversationId);
|
||||||
|
this.logger.log(
|
||||||
|
`Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`,
|
||||||
|
);
|
||||||
|
return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a batch's callback, whenever MoR gets around to sending it. Called by
|
||||||
|
* `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it,
|
||||||
|
* not a logged-in user), so the only thing standing between this and a forged callback is the
|
||||||
|
* conversation id itself: an item is only ever applied to an invoice actually holding that exact
|
||||||
|
* id, and an unknown id is logged and ignored rather than touching anything.
|
||||||
|
*/
|
||||||
|
async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise<EimsBulkRegisterItemResult[]> {
|
||||||
|
const settlements = items.filter(
|
||||||
|
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
|
||||||
|
"irn" in item || "ruleError" in item,
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationId = this.markerFrom(items);
|
||||||
|
const invoices = await this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.createQueryBuilder("invoice")
|
||||||
|
.where("invoice.eims_bulk_conversation_id = :id", { id: conversationId })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv]));
|
||||||
|
// Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN —
|
||||||
|
// the same "advance the chain" semantics as single register's settleSuccess.
|
||||||
|
const ordered = [...settlements].sort((a, b) => {
|
||||||
|
const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo);
|
||||||
|
const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo);
|
||||||
|
return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: EimsBulkRegisterItemResult[] = [];
|
||||||
|
for (const item of ordered) {
|
||||||
|
const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo;
|
||||||
|
const invoice = byDocumentNumber.get(docNumber);
|
||||||
|
if (!invoice) {
|
||||||
|
this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) {
|
||||||
|
// Already settled — a duplicate callback delivery. Report the current state, touch nothing.
|
||||||
|
results.push({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
success: invoice.eimsStatus === EimsInvoiceStatus.Registered,
|
||||||
|
message: `Already settled (${invoice.eimsStatus})`,
|
||||||
|
irn: invoice.eimsIrn ?? undefined,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("irn" in item) {
|
||||||
|
await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR);
|
||||||
|
results.push({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
success: true,
|
||||||
|
message: `Registered with EIMS (IRN ${item.irn})`,
|
||||||
|
irn: item.irn,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error";
|
||||||
|
await this.settleBulkItemFailure(invoice, message);
|
||||||
|
results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the batch's in-flight marker only once nothing submitted under this conversation is
|
||||||
|
// still waiting — a partial/incremental callback (not expected per the collection's docs, but
|
||||||
|
// not ruled out either) must not prematurely unblock the system number.
|
||||||
|
const stillPending = await this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } });
|
||||||
|
if (stillPending === 0) {
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
EimsSystemState,
|
||||||
|
{ inFlightConversationId: conversationId },
|
||||||
|
{ inFlightConversationId: null },
|
||||||
|
);
|
||||||
|
this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
||||||
|
private async reserveBulk(
|
||||||
|
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
|
||||||
|
systemNumber: string,
|
||||||
|
placeholder: string,
|
||||||
|
): Promise<BulkReservation[]> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockSystemState(manager, systemNumber);
|
||||||
|
|
||||||
|
if (state.blockedReason) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_SYSTEM_BLOCKED",
|
||||||
|
message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.inFlightInvoiceId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
||||||
|
message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.inFlightConversationId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_BULK_IN_FLIGHT",
|
||||||
|
message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = Number(state.nextInvoiceCounter);
|
||||||
|
let docNumber = Number(state.nextDocumentNumber);
|
||||||
|
let previousIrn = state.previousIrn ?? "";
|
||||||
|
const reservations: BulkReservation[] = [];
|
||||||
|
|
||||||
|
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
||||||
|
// on the opposite lock order.
|
||||||
|
for (const { invoice, documentType, relatedDocument } of prepared) {
|
||||||
|
const locked = await this.lockInvoice(manager, invoice.id);
|
||||||
|
const thisCounter = counter++;
|
||||||
|
const thisDocNumber = String(docNumber++);
|
||||||
|
const thisPreviousIrn = reservations.length === 0 ? previousIrn : "";
|
||||||
|
|
||||||
|
await manager.update(Invoice, invoice.id, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||||
|
eimsInvoiceCounter: thisCounter,
|
||||||
|
eimsDocumentNumber: thisDocNumber,
|
||||||
|
eimsSubmittedAt: new Date(),
|
||||||
|
eimsLastError: null,
|
||||||
|
eimsBulkConversationId: placeholder,
|
||||||
|
} as QueryDeepPartialEntity<Invoice>);
|
||||||
|
|
||||||
|
reservations.push({
|
||||||
|
stateId: state.id,
|
||||||
|
invoice: Object.assign(locked, { lines: invoice.lines }),
|
||||||
|
documentType,
|
||||||
|
relatedDocument,
|
||||||
|
invoiceCounter: thisCounter,
|
||||||
|
documentNumber: thisDocNumber,
|
||||||
|
previousIrn: thisPreviousIrn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.update(EimsSystemState, state.id, {
|
||||||
|
nextInvoiceCounter: counter,
|
||||||
|
nextDocumentNumber: docNumber,
|
||||||
|
inFlightConversationId: placeholder,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reservations;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */
|
||||||
|
private async claimConversationId(placeholder: string, conversationId: string): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId });
|
||||||
|
await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TX2b for the whole batch — the same determinism doctrine as single register's settleFailure,
|
||||||
|
* applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP
|
||||||
|
* call; there is no per-item answer yet at this point, only after the callback.
|
||||||
|
*/
|
||||||
|
private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise<void> {
|
||||||
|
const api = err instanceof EimsApiException ? err : null;
|
||||||
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
||||||
|
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||||
|
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||||
|
const lastError: EimsInvoiceError = {
|
||||||
|
kind: api?.kind ?? localKind,
|
||||||
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
|
httpStatus: api?.httpStatus,
|
||||||
|
details: api?.details,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const first = reservations[0];
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
for (const r of reservations) {
|
||||||
|
await manager.update(Invoice, r.invoice.id, {
|
||||||
|
eimsStatus: status,
|
||||||
|
eimsLastError: lastError,
|
||||||
|
...(deterministic ? { eimsBulkConversationId: null } : {}),
|
||||||
|
} as QueryDeepPartialEntity<Invoice>);
|
||||||
|
}
|
||||||
|
await manager.update(
|
||||||
|
EimsSystemState,
|
||||||
|
first.stateId,
|
||||||
|
deterministic
|
||||||
|
? {
|
||||||
|
// The whole block returns: MoR never counted a refused batch against either sequence.
|
||||||
|
nextInvoiceCounter: first.invoiceCounter,
|
||||||
|
nextDocumentNumber: Number(first.documentNumber),
|
||||||
|
inFlightConversationId: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
blockedReason:
|
||||||
|
`A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` +
|
||||||
|
`was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One callback item accepted. */
|
||||||
|
private async settleBulkItemSuccess(
|
||||||
|
invoice: Invoice,
|
||||||
|
irn: string,
|
||||||
|
conversationId: string,
|
||||||
|
signedQR?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.lockInvoice(manager, invoice.id);
|
||||||
|
await manager.update(Invoice, invoice.id, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: irn,
|
||||||
|
eimsSignedQr: signedQR ?? null,
|
||||||
|
eimsLastError: null,
|
||||||
|
});
|
||||||
|
// Looked up by conversation id, not system number — this batch's state row is whichever one
|
||||||
|
// is holding this conversation, which is exactly what `inFlightConversationId` already tracks.
|
||||||
|
await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn });
|
||||||
|
});
|
||||||
|
this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`);
|
||||||
|
|
||||||
|
if (invoice.companyId) {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
invoice.companyId,
|
||||||
|
`Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One callback item rejected. Unlike single register's settleFailure, the counter/document
|
||||||
|
* number are not returned — MoR's own bulk processing already advanced the whole array's
|
||||||
|
* allocation regardless of this item's individual outcome, so there is nothing local to roll back.
|
||||||
|
*/
|
||||||
|
private async settleBulkItemFailure(invoice: Invoice, message: string): Promise<void> {
|
||||||
|
const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() };
|
||||||
|
await this.dataSource.manager.update(Invoice, invoice.id, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsLastError: lastError,
|
||||||
|
} as QueryDeepPartialEntity<Invoice>);
|
||||||
|
this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private markerFrom(items: EimsBulkCallbackItem[]): string {
|
||||||
|
const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as
|
||||||
|
| { conversationId?: string; conversionId?: string }
|
||||||
|
| undefined;
|
||||||
|
return marker?.conversationId ?? marker?.conversionId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await manager
|
||||||
|
.createQueryBuilder(Invoice, "invoice")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("invoice.id = :invoiceId", { invoiceId })
|
||||||
|
.getOne();
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockSystemState(manager: EntityManager, systemNumber: string): Promise<EimsSystemState> {
|
||||||
|
const select = () =>
|
||||||
|
manager
|
||||||
|
.createQueryBuilder(EimsSystemState, "state")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("state.system_number = :systemNumber", { systemNumber })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
const existing = await select();
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`,
|
||||||
|
[systemNumber],
|
||||||
|
);
|
||||||
|
const created = await select();
|
||||||
|
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
|
||||||
|
const invoices = await this.dataSource.getRepository(Invoice).find({
|
||||||
|
where: { id: In(invoiceIds) },
|
||||||
|
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
||||||
|
});
|
||||||
|
const found = new Set(invoices.map((inv) => inv.id));
|
||||||
|
const missing = invoiceIds.filter((id) => !found.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: Array<EimsMapperLine & { invoiceId: string }> = await this.dataSource.query(
|
||||||
|
`SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity,
|
||||||
|
unit_rate AS "unitRate", amount, currency, metadata
|
||||||
|
FROM freight.invoice_lines
|
||||||
|
WHERE invoice_id = ANY($1) AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
[invoiceIds],
|
||||||
|
);
|
||||||
|
const linesByInvoice = new Map<string, EimsMapperLine[]>();
|
||||||
|
for (const line of lines) {
|
||||||
|
const { invoiceId, ...rest } = line;
|
||||||
|
if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []);
|
||||||
|
linesByInvoice.get(invoiceId)!.push(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the caller's given order — reservation and result ordering both depend on it.
|
||||||
|
return invoiceIds.map((id) => {
|
||||||
|
const invoice = invoices.find((inv) => inv.id === id)!;
|
||||||
|
return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import { BookingStaff } from "../../common/booking-guards";
|
|||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { sendPdf } from "../billing/billing.controller";
|
import { sendPdf } from "../billing/billing.controller";
|
||||||
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
||||||
|
import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto";
|
||||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||||
|
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
import { EimsReceiptService } from "./eims-receipt.service";
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
@@ -39,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service";
|
|||||||
export class EimsInvoiceController {
|
export class EimsInvoiceController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly registration: EimsInvoiceRegistrationService,
|
private readonly registration: EimsInvoiceRegistrationService,
|
||||||
|
private readonly bulkRegistration: EimsBulkRegistrationService,
|
||||||
private readonly cancellation: EimsCancellationService,
|
private readonly cancellation: EimsCancellationService,
|
||||||
private readonly receipts: EimsReceiptService,
|
private readonly receipts: EimsReceiptService,
|
||||||
) {}
|
) {}
|
||||||
@@ -53,6 +56,18 @@ export class EimsInvoiceController {
|
|||||||
return this.registration.registerInvoiceWithEims(id);
|
return this.registration.registerInvoiceWithEims(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("eims/bulk-register")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " +
|
||||||
|
"accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " +
|
||||||
|
"rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.",
|
||||||
|
})
|
||||||
|
bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) {
|
||||||
|
return this.bulkRegistration.registerBulk(dto.invoiceIds);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/eims/verify")
|
@Post(":id/eims/verify")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||||
import { EimsErrorResponse } from "./eims.types";
|
import { EimsErrorResponse } from "./eims.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +130,62 @@ export interface EimsBulkCancelItemResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest`
|
||||||
|
* from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope.
|
||||||
|
*/
|
||||||
|
export type EimsBulkRegisterRequest = EimsInvoiceRequest[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate response to `bulkRegister` — unlike single register, this is not the result, just an
|
||||||
|
* acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to
|
||||||
|
* a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`).
|
||||||
|
*/
|
||||||
|
export interface EimsBulkRegisterAcceptedResponse {
|
||||||
|
conversationId: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A settled item in the async callback — `irn` present means MoR accepted this document. */
|
||||||
|
export interface EimsBulkCallbackSuccessItem {
|
||||||
|
irn: string;
|
||||||
|
status: string;
|
||||||
|
documentNumber: string;
|
||||||
|
signedQR?: string;
|
||||||
|
signedInvoice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */
|
||||||
|
export interface EimsBulkCallbackErrorItem {
|
||||||
|
ruleError: Array<{ portion: string; errorMessage: string[] }>;
|
||||||
|
status: string;
|
||||||
|
docNo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback array's last element, per the collection's own examples — never a settlement result,
|
||||||
|
* just the batch id echoed back. Spelled two different ways across the collection's own docs
|
||||||
|
* ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both.
|
||||||
|
*/
|
||||||
|
export interface EimsBulkCallbackMarker {
|
||||||
|
conversationId?: string;
|
||||||
|
conversionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EimsBulkCallbackItem =
|
||||||
|
| EimsBulkCallbackSuccessItem
|
||||||
|
| EimsBulkCallbackErrorItem
|
||||||
|
| EimsBulkCallbackMarker;
|
||||||
|
|
||||||
|
/** One invoice's outcome once a bulk batch's callback has been processed. */
|
||||||
|
export interface EimsBulkRegisterItemResult {
|
||||||
|
invoiceId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
irn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Body, Controller, HttpCode, Post } from "@nestjs/common";
|
||||||
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
import { EimsBulkCallbackItem } from "./eims-registration.types";
|
||||||
|
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no
|
||||||
|
* JWT to send — so the conversation id embedded in the payload is the only thing standing between
|
||||||
|
* this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches
|
||||||
|
* invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the
|
||||||
|
* "Callback Mechanism" section of the collection's own docs for the payload shape.
|
||||||
|
*/
|
||||||
|
@ApiExcludeController()
|
||||||
|
@Controller("eims/webhook")
|
||||||
|
export class EimsWebhookController {
|
||||||
|
constructor(private readonly bulk: EimsBulkRegistrationService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post("bulk-register")
|
||||||
|
@HttpCode(200)
|
||||||
|
bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) {
|
||||||
|
return this.bulk.handleBulkCallback(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo
|
|||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
|
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
@@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
|||||||
import { EimsReceiptService } from "./eims-receipt.service";
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSignerService } from "./eims-signer.service";
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
|
import { EimsWebhookController } from "./eims-webhook.controller";
|
||||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
|
||||||
@@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
// so this stays a plain one-directional import, not a new cycle.
|
// so this stays a plain one-directional import, not a new cycle.
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController, EimsWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
EimsCredentialsProvider,
|
EimsCredentialsProvider,
|
||||||
EimsSignerService,
|
EimsSignerService,
|
||||||
EimsAuthService,
|
EimsAuthService,
|
||||||
EimsClientService,
|
EimsClientService,
|
||||||
EimsInvoiceRegistrationService,
|
EimsInvoiceRegistrationService,
|
||||||
|
EimsBulkRegistrationService,
|
||||||
EimsAutoSubmitService,
|
EimsAutoSubmitService,
|
||||||
EimsCancellationService,
|
EimsCancellationService,
|
||||||
EimsReceiptService,
|
EimsReceiptService,
|
||||||
@@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
EimsAuthService,
|
EimsAuthService,
|
||||||
EimsClientService,
|
EimsClientService,
|
||||||
EimsInvoiceRegistrationService,
|
EimsInvoiceRegistrationService,
|
||||||
|
EimsBulkRegistrationService,
|
||||||
EimsCancellationService,
|
EimsCancellationService,
|
||||||
EimsReceiptService,
|
EimsReceiptService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
||||||
blockedReason?: string | null;
|
blockedReason?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding
|
||||||
|
* while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`.
|
||||||
|
*/
|
||||||
|
@Column({ name: "in_flight_conversation_id", type: "text", nullable: true })
|
||||||
|
inFlightConversationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||||
|
import { Contract } from '../../contracts/entities/contract.entity';
|
||||||
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||||
|
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||||
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
|
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
|
||||||
|
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
|
||||||
|
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
|
||||||
|
* overrides `total_amount`. Getting either wrong misreports money or weight.
|
||||||
|
*/
|
||||||
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||||
|
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
|
||||||
|
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
|
||||||
|
'ARRIVED', 'DELIVERED', 'COMPLETED',
|
||||||
|
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||||
|
|
||||||
|
export const bookingsDataset: ExportDataset = {
|
||||||
|
key: 'bookings',
|
||||||
|
title: 'Bookings',
|
||||||
|
description: 'Every booking, with customer, route, cargo, contract and payment detail',
|
||||||
|
group: 'Commercial',
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
|
base: { entity: Booking, alias: 'b' },
|
||||||
|
|
||||||
|
// Every join is a LEFT join (see ExportJoin) — ticking a field must never
|
||||||
|
// change which rows come back.
|
||||||
|
joins: [
|
||||||
|
{ alias: 'c', entity: Company, on: 'c.id = b.company_id' },
|
||||||
|
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = b.company_profile_id' },
|
||||||
|
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = b.shipping_line_company_id' },
|
||||||
|
{ alias: 'o', entity: Yard, on: 'o.id = b.origin_yard_id' },
|
||||||
|
{ alias: 'd', entity: Yard, on: 'd.id = b.destination_yard_id' },
|
||||||
|
{ alias: 'cty', entity: CargoType, on: 'cty.id = b.cargo_type_id' },
|
||||||
|
{ alias: 'st', entity: ServiceType, on: 'st.id = b.service_type_id' },
|
||||||
|
{ alias: 'sl', entity: ShippingLine, on: 'sl.id = b.shipping_line_id' },
|
||||||
|
{ alias: 'ct', entity: Contract, on: 'ct.id = b.contract_id' },
|
||||||
|
{ alias: 't', entity: Train, on: 't.id = b.train_id' },
|
||||||
|
// Transitive: the contract's own customer, reachable only once `ct` is in.
|
||||||
|
{ alias: 'ctc', entity: Company, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
||||||
|
],
|
||||||
|
// `search` matches the customer name, so `c` is always present — which is
|
||||||
|
// also why the count query joins it.
|
||||||
|
alwaysJoin: ['c'],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'booking', label: 'Booking' },
|
||||||
|
{ id: 'customer', label: 'Customer' },
|
||||||
|
{ id: 'route', label: 'Route' },
|
||||||
|
{ id: 'cargo', label: 'Cargo' },
|
||||||
|
{ id: 'payment', label: 'Payment' },
|
||||||
|
{ id: 'scheduling', label: 'Scheduling' },
|
||||||
|
{ id: 'contract', label: 'Contract' },
|
||||||
|
{ id: 'firstMile', label: 'First mile' },
|
||||||
|
{ id: 'lastMile', label: 'Last mile' },
|
||||||
|
{ id: 'clearance', label: 'Clearance' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
// ---- Booking -------------------------------------------------------
|
||||||
|
{ key: 'reference', label: 'Reference', type: 'string', group: 'booking', default: true, select: 'b.reference', sortExpr: 'b.reference' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'booking', default: true, select: 'b.status', sortExpr: 'b.status' },
|
||||||
|
{ key: 'bookingType', label: 'Booking type', type: 'string', group: 'booking', select: 'b.booking_type' },
|
||||||
|
{ key: 'contractKind', label: 'Contract kind', type: 'string', group: 'booking', select: 'b.contract_kind' },
|
||||||
|
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'booking', default: true, select: `to_char(b.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.created_at' },
|
||||||
|
{ key: 'updatedAt', label: 'Updated', type: 'datetime', group: 'booking', select: `to_char(b.updated_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.updated_at' },
|
||||||
|
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'booking', select: `to_char(b.expires_at, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'createdByRole', label: 'Created by role', type: 'string', group: 'booking', select: 'b.created_by_role' },
|
||||||
|
{ key: 'isSplit', label: 'Split booking', type: 'boolean', group: 'booking', select: 'b.is_split' },
|
||||||
|
{ key: 'priorityScore', label: 'Priority score', type: 'number', group: 'booking', select: 'b.priority_score', sortExpr: 'b.priority_score' },
|
||||||
|
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'booking', select: 'b.version_number' },
|
||||||
|
{ key: 'pnrCode', label: 'PNR code', type: 'string', group: 'booking', select: 'b.pnr_code' },
|
||||||
|
|
||||||
|
// ---- Customer (the "more than the UI shows" payload) ----------------
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||||
|
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
|
||||||
|
{ key: 'customerKind', label: 'Customer kind', type: 'string', group: 'customer', requires: ['c'], select: 'c.kind' },
|
||||||
|
{ key: 'customerStatus', label: 'Customer status', type: 'string', group: 'customer', requires: ['c'], select: 'c.status' },
|
||||||
|
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||||
|
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
|
||||||
|
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||||
|
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||||
|
{ key: 'customerContact', label: 'Contact person', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_name' },
|
||||||
|
{ key: 'customerContactPhone', label: 'Contact phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_phone' },
|
||||||
|
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
|
||||||
|
{ key: 'customerCountry', label: 'Customer country', type: 'string', group: 'customer', requires: ['c'], select: 'c.country' },
|
||||||
|
{ key: 'customerRegion', label: 'Customer region', type: 'string', group: 'customer', requires: ['c'], select: 'c.region' },
|
||||||
|
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||||
|
{ key: 'customerProfileType', label: 'Profile type', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.type' },
|
||||||
|
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'b.is_government' },
|
||||||
|
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'b.government_institution' },
|
||||||
|
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
|
||||||
|
|
||||||
|
// ---- Route ----------------------------------------------------------
|
||||||
|
{ key: 'origin', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['o'], select: 'o.label' },
|
||||||
|
{ key: 'originCode', label: 'Origin code', type: 'string', group: 'route', requires: ['o'], select: 'o.code' },
|
||||||
|
{ key: 'destination', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['d'], select: 'd.label' },
|
||||||
|
{ key: 'destinationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['d'], select: 'd.code' },
|
||||||
|
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'route', default: true, select: 'b.trade_direction', sortExpr: 'b.trade_direction' },
|
||||||
|
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
|
||||||
|
|
||||||
|
// ---- Cargo -----------------------------------------------------------
|
||||||
|
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||||
|
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
|
||||||
|
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
|
||||||
|
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
|
||||||
|
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
|
||||||
|
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
|
||||||
|
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
|
||||||
|
{ key: 'shippingLine', label: 'Shipping line', type: 'string', group: 'cargo', requires: ['sl'], select: 'sl.label' },
|
||||||
|
{
|
||||||
|
// One-to-many, so it aggregates in a correlated subquery rather than a
|
||||||
|
// join — a join here would multiply rows and break the count contract.
|
||||||
|
key: 'containerNumbers', label: 'Container numbers', type: 'string', group: 'cargo',
|
||||||
|
select: `(SELECT string_agg(bc.container_number, ' | ' ORDER BY bc.container_number)
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Payment ----------------------------------------------------------
|
||||||
|
{ key: 'amount', label: 'Amount', type: 'money', group: 'payment', default: true, select: `ROUND(${REVENUE}, 2)::float8`, sortExpr: REVENUE },
|
||||||
|
{ key: 'totalAmount', label: 'Total amount (pre-adjustment)', type: 'money', group: 'payment', select: 'b.total_amount::float8' },
|
||||||
|
{ key: 'adjustedTotalAmount', label: 'Adjusted total', type: 'money', group: 'payment', select: 'b.adjusted_total_amount::float8' },
|
||||||
|
{ key: 'adjustmentReason', label: 'Adjustment reason', type: 'string', group: 'payment', select: 'b.adjustment_reason' },
|
||||||
|
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', default: true, select: 'b.payment_status', sortExpr: 'b.payment_status' },
|
||||||
|
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'payment', select: 'b.payment_currency' },
|
||||||
|
{ key: 'paymentDeadline', label: 'Payment deadline', type: 'datetime', group: 'payment', select: `to_char(b.payment_deadline, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
// ---- Scheduling --------------------------------------------------------
|
||||||
|
{ key: 'scheduledDate', label: 'Scheduled date', type: 'date', group: 'scheduling', default: true, select: `to_char(b.scheduled_date, 'YYYY-MM-DD')`, sortExpr: 'b.scheduled_date' },
|
||||||
|
{ key: 'schedulingStatus', label: 'Scheduling status', type: 'string', group: 'scheduling', select: 'b.scheduling_status' },
|
||||||
|
{ key: 'wagonsRequired', label: 'Wagons required', type: 'number', group: 'scheduling', select: 'b.wagons_required' },
|
||||||
|
{ key: 'trainCode', label: 'Train', type: 'string', group: 'scheduling', requires: ['t'], select: 't.code' },
|
||||||
|
{ key: 'loadedAt', label: 'Loaded at', type: 'datetime', group: 'scheduling', select: `to_char(b.loaded_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'arrivedAt', label: 'Arrived at', type: 'datetime', group: 'scheduling', select: `to_char(b.arrived_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
// ---- Contract ----------------------------------------------------------
|
||||||
|
{ key: 'contractReference', label: 'Contract reference', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.reference' },
|
||||||
|
{ key: 'contractStatus', label: 'Contract status', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.status' },
|
||||||
|
{ key: 'contractCustomer', label: 'Contract customer', type: 'string', group: 'contract', requires: ['ctc'], select: 'ctc.name' },
|
||||||
|
{ key: 'contractType', label: 'Contract type', type: 'string', group: 'contract', select: 'b.contract_type' },
|
||||||
|
{ key: 'contractValidFrom', label: 'Contract valid from', type: 'date', group: 'contract', select: `to_char(b.contract_valid_from, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'contractValidUntil', label: 'Contract valid until', type: 'date', group: 'contract', select: `to_char(b.contract_valid_until, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'fullyExecutedAt', label: 'Fully executed at', type: 'datetime', group: 'contract', select: `to_char(b.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
// ---- First / last mile ---------------------------------------------------
|
||||||
|
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'firstMile', select: 'b.first_mile_pickup_address' },
|
||||||
|
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'lastMile', select: 'b.last_mile_delivery_address' },
|
||||||
|
{ key: 'customerTruckPlate', label: 'Customer truck plate', type: 'string', group: 'lastMile', select: 'b.customer_truck_plate_number' },
|
||||||
|
{ key: 'customerTruckDriver', label: 'Customer truck driver', type: 'string', group: 'lastMile', select: 'b.customer_truck_driver_name' },
|
||||||
|
{ key: 'exportHandoverMode', label: 'Handover mode', type: 'string', group: 'lastMile', select: 'b.export_handover_mode' },
|
||||||
|
|
||||||
|
// ---- Clearance -------------------------------------------------------------
|
||||||
|
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'b.customs_clearing_enabled' },
|
||||||
|
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'b.customs_clearing_agent' },
|
||||||
|
{ key: 'clearancePhase', label: 'Clearance phase', type: 'string', group: 'clearance', select: 'b.clearance_current_phase' },
|
||||||
|
{ key: 'dutyRequired', label: 'Duty required', type: 'boolean', group: 'clearance', select: 'b.duty_required' },
|
||||||
|
{ key: 'vesselArrivalDate', label: 'Vessel arrival', type: 'date', group: 'clearance', select: `to_char(b.vessel_arrival_date, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'doCollectedDate', label: 'DO collected', type: 'date', group: 'clearance', select: `to_char(b.do_collected_date, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||||
|
{
|
||||||
|
key: 'tradeDirection', label: 'Direction', type: 'select',
|
||||||
|
options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'freightType', label: 'Freight type', type: 'select',
|
||||||
|
options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })),
|
||||||
|
},
|
||||||
|
{ key: 'paymentStatus', label: 'Payment status', type: 'select', options: [
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'PNR_GENERATED', label: 'PNR generated' },
|
||||||
|
{ value: 'VERIFICATION_IN_PROGRESS', label: 'Verification in progress' },
|
||||||
|
{ value: 'PAID', label: 'Paid' },
|
||||||
|
{ value: 'FAILED', label: 'Failed' },
|
||||||
|
] },
|
||||||
|
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
// andWhere, not where: `where()` resets any condition already on the
|
||||||
|
// builder, so scope() would silently drop anything a caller added first.
|
||||||
|
qb.andWhere('b.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (params.createdFrom) qb.andWhere('b.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||||
|
if (params.createdTo) qb.andWhere('b.created_at < :createdTo', { createdTo: params.createdTo });
|
||||||
|
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses?.length) qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
|
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||||
|
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||||
|
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
|
||||||
|
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trade-direction ACL. Without this the export returns rows the user's own
|
||||||
|
// list page would not show them.
|
||||||
|
applyDirectionScope(qb, 'b.trade_direction', directions);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||||
|
import { Contract } from '../../contracts/entities/contract.entity';
|
||||||
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||||
|
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
export const contractsDataset: ExportDataset = {
|
||||||
|
key: 'contracts',
|
||||||
|
title: 'Contracts',
|
||||||
|
description: 'Contracts with customer, terms, routes, approval and clearance detail',
|
||||||
|
group: 'Commercial',
|
||||||
|
permission: FREIGHT_PERMS.contracts.view,
|
||||||
|
base: { entity: Contract, alias: 'ct' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
{ alias: 'c', entity: Company, on: 'c.id = ct.company_id' },
|
||||||
|
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = ct.company_profile_id' },
|
||||||
|
{ alias: 'st', entity: ServiceType, on: 'st.id = ct.service_type_id' },
|
||||||
|
{ alias: 'ren', entity: Contract, on: 'ren.id = ct.renewal_of_id' },
|
||||||
|
],
|
||||||
|
// `search` matches the customer name.
|
||||||
|
alwaysJoin: ['c'],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'contract', label: 'Contract' },
|
||||||
|
{ id: 'customer', label: 'Customer' },
|
||||||
|
{ id: 'terms', label: 'Terms' },
|
||||||
|
{ id: 'routes', label: 'Routes & cargo' },
|
||||||
|
{ id: 'approval', label: 'Approval' },
|
||||||
|
{ id: 'clearance', label: 'Clearance' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'reference', label: 'Reference', type: 'string', group: 'contract', default: true, select: 'ct.reference', sortExpr: 'ct.reference' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'contract', default: true, select: 'ct.status', sortExpr: 'ct.status' },
|
||||||
|
{ key: 'contractKind', label: 'Kind', type: 'string', group: 'contract', default: true, select: 'ct.contract_kind' },
|
||||||
|
{ key: 'contractType', label: 'Type', type: 'string', group: 'contract', select: 'ct.contract_type' },
|
||||||
|
{ key: 'createdAt', label: 'Created', type: 'date', group: 'contract', default: true, select: `to_char(ct.created_at, 'YYYY-MM-DD')`, sortExpr: 'ct.created_at' },
|
||||||
|
{ key: 'submittedAt', label: 'Submitted', type: 'datetime', group: 'contract', select: `to_char(ct.submitted_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'contract', select: 'ct.version_number' },
|
||||||
|
{ key: 'renewalOf', label: 'Renewal of', type: 'string', group: 'contract', requires: ['ren'], select: 'ren.reference' },
|
||||||
|
{ key: 'statusBeforeSuspension', label: 'Status before suspension', type: 'string', group: 'contract', select: 'ct.status_before_suspension' },
|
||||||
|
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||||
|
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||||
|
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||||
|
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||||
|
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
|
||||||
|
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||||
|
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'ct.is_government' },
|
||||||
|
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'ct.government_institution' },
|
||||||
|
|
||||||
|
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'terms', default: true, select: 'ct.trade_direction', sortExpr: 'ct.trade_direction' },
|
||||||
|
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'terms', default: true, select: 'ct.freight_type' },
|
||||||
|
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'terms', requires: ['st'], select: 'st.service_name' },
|
||||||
|
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'terms', select: 'ct.payment_currency' },
|
||||||
|
{ key: 'validFrom', label: 'Valid from', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, sortExpr: 'ct.contract_valid_from' },
|
||||||
|
{ key: 'validUntil', label: 'Valid until', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_until, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'validityDays', label: 'Validity (days)', type: 'number', group: 'terms', select: 'ct.contract_validity_days' },
|
||||||
|
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'terms', select: `to_char(ct.expires_at, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'estimatedShipmentDate', label: 'Est. shipment date', type: 'date', group: 'terms', select: `to_char(ct.estimated_shipment_date, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'equipmentReturn', label: 'Equipment return', type: 'string', group: 'terms', select: 'ct.equipment_return' },
|
||||||
|
{ key: 'pricingDisplayMode', label: 'Pricing display mode', type: 'string', group: 'terms', select: 'ct.pricing_display_mode' },
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'routes', label: 'Routes', type: 'string', group: 'routes',
|
||||||
|
select: `(SELECT string_agg(o.label || ' -> ' || d.label, ' | ' ORDER BY cr.sort_order)
|
||||||
|
FROM freight.contract_routes cr
|
||||||
|
JOIN freight.yards o ON o.id = cr.origin_yard_id
|
||||||
|
JOIN freight.yards d ON d.id = cr.destination_yard_id
|
||||||
|
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'routeCount', label: 'Route count', type: 'number', group: 'routes',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.contract_routes cr
|
||||||
|
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'routes',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||||
|
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'routes', select: 'ct.is_hazardous' },
|
||||||
|
{ key: 'hazardClass', label: 'Hazard class', type: 'string', group: 'routes', select: 'ct.hazard_class' },
|
||||||
|
{ key: 'unNumber', label: 'UN number', type: 'string', group: 'routes', select: 'ct.un_number' },
|
||||||
|
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'routes', select: 'ct.is_reefer' },
|
||||||
|
|
||||||
|
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'approval', select: `to_char(ct.approved_by_staff_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'signedByDirectorAt', label: 'Director signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_director_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'signedByCeoAt', label: 'CEO signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_ceo_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'customerSignedAt', label: 'Customer signed', type: 'datetime', group: 'approval', select: `to_char(ct.customer_signed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'fullyExecutedAt', label: 'Fully executed', type: 'datetime', group: 'approval', default: true, select: `to_char(ct.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'lockedAt', label: 'Locked at', type: 'datetime', group: 'approval', select: `to_char(ct.locked_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'contractGeneratedAt', label: 'Document generated', type: 'datetime', group: 'approval', select: `to_char(ct.contract_generated_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
{ key: 'clearanceStatus', label: 'Clearance status', type: 'string', group: 'clearance', select: 'ct.clearance_status' },
|
||||||
|
{ key: 'clearanceCycleNumber', label: 'Clearance cycle', type: 'number', group: 'clearance', select: 'ct.clearance_cycle_number' },
|
||||||
|
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'ct.customs_clearing_enabled' },
|
||||||
|
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'ct.customs_clearing_agent' },
|
||||||
|
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'clearance', select: 'ct.first_mile_pickup_address' },
|
||||||
|
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'clearance', select: 'ct.last_mile_delivery_address' },
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||||
|
{ key: 'contractKind', label: 'Kind', type: 'text' },
|
||||||
|
{ key: 'tradeDirection', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
|
||||||
|
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
|
||||||
|
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
|
||||||
|
{ value: 'ETB', label: 'ETB' },
|
||||||
|
{ value: 'USD', label: 'USD' },
|
||||||
|
] },
|
||||||
|
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
|
||||||
|
// Routes are one-to-many on contract_routes, so these filter via EXISTS
|
||||||
|
// rather than a column comparison.
|
||||||
|
{ key: 'originYardId', label: 'Origin', type: 'text' },
|
||||||
|
{ key: 'destinationYardId', label: 'Destination', type: 'text' },
|
||||||
|
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
qb.andWhere('ct.deleted_at IS NULL');
|
||||||
|
if (params.createdFrom) qb.andWhere('ct.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||||
|
if (params.createdTo) qb.andWhere('ct.created_at < :createdTo', { createdTo: params.createdTo });
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses?.length) qb.andWhere('ct.status IN (:...statuses)', { statuses });
|
||||||
|
if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind });
|
||||||
|
if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||||
|
if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType });
|
||||||
|
if (params.paymentCurrency) qb.andWhere('ct.payment_currency = :paymentCurrency', { paymentCurrency: params.paymentCurrency });
|
||||||
|
if (params.serviceTypeId) qb.andWhere('ct.service_type_id = :serviceTypeId', { serviceTypeId: params.serviceTypeId });
|
||||||
|
if (params.originYardId) {
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (SELECT 1 FROM freight.contract_routes cr
|
||||||
|
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL
|
||||||
|
AND cr.origin_yard_id = :originYardId)`,
|
||||||
|
{ originYardId: params.originYardId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.destinationYardId) {
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (SELECT 1 FROM freight.contract_routes cr2
|
||||||
|
WHERE cr2.contract_id = ct.id AND cr2.deleted_at IS NULL
|
||||||
|
AND cr2.destination_yard_id = :destinationYardId)`,
|
||||||
|
{ destinationYardId: params.destinationYardId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||||
|
}
|
||||||
|
applyDirectionScope(qb, 'ct.trade_direction', directions);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE ROW PER COMPANY. A company has many `company_profiles`, so every
|
||||||
|
* profile-derived field aggregates in a subquery rather than joining — a join
|
||||||
|
* would multiply rows and make the file disagree with the count endpoint.
|
||||||
|
* If per-profile rows are ever wanted, that is a separate `company-profiles`
|
||||||
|
* dataset, not a flag on this one.
|
||||||
|
*/
|
||||||
|
export const customersDataset: ExportDataset = {
|
||||||
|
key: 'customers',
|
||||||
|
title: 'Customers',
|
||||||
|
description: 'Companies with registration, contact, address and activity detail',
|
||||||
|
group: 'Commercial',
|
||||||
|
permission: FREIGHT_PERMS.customers.view,
|
||||||
|
base: { entity: Company, alias: 'c' },
|
||||||
|
joins: [],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'identity', label: 'Identity' },
|
||||||
|
{ id: 'registration', label: 'Registration' },
|
||||||
|
{ id: 'contact', label: 'Contact' },
|
||||||
|
{ id: 'address', label: 'Address' },
|
||||||
|
{ id: 'profiles', label: 'Profiles' },
|
||||||
|
{ id: 'activity', label: 'Activity' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'name', label: 'Company', type: 'string', group: 'identity', default: true, select: 'c.name', sortExpr: 'c.name' },
|
||||||
|
{ key: 'type', label: 'Type', type: 'string', group: 'identity', default: true, select: 'c.type' },
|
||||||
|
{ key: 'kind', label: 'Kind', type: 'string', group: 'identity', default: true, select: 'c.kind' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'identity', default: true, select: 'c.status', sortExpr: 'c.status' },
|
||||||
|
{ key: 'statusDescription', label: 'Status note', type: 'string', group: 'identity', select: 'c.status_description' },
|
||||||
|
{ key: 'nationality', label: 'Nationality', type: 'string', group: 'identity', select: 'c.nationality' },
|
||||||
|
|
||||||
|
// date_registered / renewal_date / renewed_* are varchar in the schema,
|
||||||
|
// not dates — exported verbatim rather than pushed through to_char.
|
||||||
|
{ key: 'tin', label: 'TIN', type: 'string', group: 'registration', default: true, select: 'c.tin' },
|
||||||
|
{ key: 'vatNumber', label: 'VAT number', type: 'string', group: 'registration', select: 'c.vat_number' },
|
||||||
|
{ key: 'fanNumber', label: 'FAN number', type: 'string', group: 'registration', select: 'c.fan_number' },
|
||||||
|
{ key: 'licenceNumber', label: 'Licence number', type: 'string', group: 'registration', select: 'c.licence_number' },
|
||||||
|
{ key: 'dateRegistered', label: 'Date registered', type: 'string', group: 'registration', select: 'c.date_registered' },
|
||||||
|
{ key: 'renewalDate', label: 'Renewal date', type: 'string', group: 'registration', select: 'c.renewal_date' },
|
||||||
|
{ key: 'renewedFrom', label: 'Renewed from', type: 'string', group: 'registration', select: 'c.renewed_from' },
|
||||||
|
{ key: 'renewedTo', label: 'Renewed to', type: 'string', group: 'registration', select: 'c.renewed_to' },
|
||||||
|
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'registration', select: `to_char(c.approved_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
{ key: 'phone', label: 'Phone', type: 'string', group: 'contact', default: true, select: 'c.phone' },
|
||||||
|
{ key: 'email', label: 'Email', type: 'string', group: 'contact', default: true, select: 'c.email' },
|
||||||
|
{ key: 'etradePhone', label: 'eTrade phone', type: 'string', group: 'contact', select: 'c.etrade_phone' },
|
||||||
|
{ key: 'website', label: 'Website', type: 'string', group: 'contact', select: 'c.website' },
|
||||||
|
{ key: 'contactPersonName', label: 'Contact person', type: 'string', group: 'contact', select: 'c.contact_person_name' },
|
||||||
|
{ key: 'contactPersonPhone', label: 'Contact phone', type: 'string', group: 'contact', select: 'c.contact_person_phone' },
|
||||||
|
|
||||||
|
{ key: 'country', label: 'Country', type: 'string', group: 'address', select: 'c.country' },
|
||||||
|
{ key: 'region', label: 'Region', type: 'string', group: 'address', select: 'c.region' },
|
||||||
|
{ key: 'zone', label: 'Zone', type: 'string', group: 'address', select: 'c.zone' },
|
||||||
|
{ key: 'woreda', label: 'Woreda', type: 'string', group: 'address', select: 'c.woreda' },
|
||||||
|
{ key: 'kebele', label: 'Kebele', type: 'string', group: 'address', select: 'c.kebele' },
|
||||||
|
{ key: 'houseNo', label: 'House no.', type: 'string', group: 'address', select: 'c.house_no' },
|
||||||
|
{ key: 'address', label: 'Address', type: 'string', group: 'address', select: 'c.address' },
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'profileCount', label: 'Profile count', type: 'number', group: 'profiles', default: true,
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileTypes', label: 'Profile types', type: 'string', group: 'profiles',
|
||||||
|
select: `(SELECT string_agg(DISTINCT cp.type, ' | ') FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileReferences', label: 'Profile references', type: 'string', group: 'profiles',
|
||||||
|
select: `(SELECT string_agg(cp.reference, ' | ' ORDER BY cp.reference) FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profileStatuses', label: 'Profile statuses', type: 'string', group: 'profiles',
|
||||||
|
select: `(SELECT string_agg(DISTINCT cp.status, ' | ') FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{ key: 'createdAt', label: 'Registered on', type: 'date', group: 'activity', default: true, select: `to_char(c.created_at, 'YYYY-MM-DD')`, sortExpr: 'c.created_at' },
|
||||||
|
{
|
||||||
|
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'activity',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||||
|
WHERE b.company_id = c.id AND b.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contractCount', label: 'Contracts', type: 'number', group: 'activity',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.contracts ct
|
||||||
|
WHERE ct.company_id = c.id AND ct.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'invoicedTotal', label: 'Invoiced total', type: 'money', group: 'activity',
|
||||||
|
select: `(SELECT ROUND(COALESCE(SUM(i.total_amount), 0), 2)::float8 FROM freight.invoices i
|
||||||
|
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'outstandingBalance', label: 'Outstanding balance', type: 'money', group: 'activity',
|
||||||
|
select: `(SELECT ROUND(COALESCE(SUM(i.balance_amount), 0), 2)::float8 FROM freight.invoices i
|
||||||
|
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'created', label: 'Registered', type: 'daterange' },
|
||||||
|
{ key: 'type', label: 'Type', type: 'text' },
|
||||||
|
{ key: 'kind', label: 'Kind', type: 'select', options: [
|
||||||
|
{ value: 'commercial', label: 'Commercial' },
|
||||||
|
{ value: 'government', label: 'Government' },
|
||||||
|
] },
|
||||||
|
{ key: 'status', label: 'Status', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search name, TIN or email', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'name', dir: 'ASC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params } = ctx;
|
||||||
|
qb.andWhere('c.deleted_at IS NULL');
|
||||||
|
if (params.createdFrom) qb.andWhere('c.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||||
|
if (params.createdTo) qb.andWhere('c.created_at < :createdTo', { createdTo: params.createdTo });
|
||||||
|
if (params.type) qb.andWhere('c.type = :type', { type: params.type });
|
||||||
|
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
|
||||||
|
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
|
||||||
|
search: `%${params.search as string}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Companies carry no trade direction — nothing to scope. Intentional.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||||
|
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||||
|
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
|
||||||
|
* signature blob) and `eims_last_error` (a raw error dump). The
|
||||||
|
* human-meaningful status/IRN/document-number fields are kept.
|
||||||
|
*/
|
||||||
|
export const invoicesDataset: ExportDataset = {
|
||||||
|
key: 'invoices',
|
||||||
|
title: 'Invoices',
|
||||||
|
description: 'Invoices with customer, amounts, payment status and EIMS state',
|
||||||
|
group: 'Finance',
|
||||||
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
|
base: { entity: Invoice, alias: 'i' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
{ alias: 'c', entity: Company, on: 'c.id = i.company_id' },
|
||||||
|
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' },
|
||||||
|
// No relation object on the entity for this FK — the service hydrates it
|
||||||
|
// with a second query. In a dataset it is just a join by column.
|
||||||
|
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
|
||||||
|
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
|
||||||
|
],
|
||||||
|
alwaysJoin: ['c'],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'invoice', label: 'Invoice' },
|
||||||
|
{ id: 'customer', label: 'Customer' },
|
||||||
|
{ id: 'amounts', label: 'Amounts' },
|
||||||
|
{ id: 'payment', label: 'Payment' },
|
||||||
|
{ id: 'lines', label: 'Lines' },
|
||||||
|
{ id: 'eims', label: 'EIMS' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' },
|
||||||
|
{ key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' },
|
||||||
|
{ key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' },
|
||||||
|
{ key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' },
|
||||||
|
{ key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' },
|
||||||
|
{ key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' },
|
||||||
|
{ key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' },
|
||||||
|
{ key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' },
|
||||||
|
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||||
|
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||||
|
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
|
||||||
|
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||||
|
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||||
|
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
|
||||||
|
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||||
|
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
|
||||||
|
|
||||||
|
{ key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' },
|
||||||
|
{ key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' },
|
||||||
|
{ key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' },
|
||||||
|
{ key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' },
|
||||||
|
{ key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' },
|
||||||
|
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
|
||||||
|
|
||||||
|
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{
|
||||||
|
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
|
||||||
|
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
|
||||||
|
THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'lineCount', label: 'Line count', type: 'number', group: 'lines',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il
|
||||||
|
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines',
|
||||||
|
select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type)
|
||||||
|
FROM freight.invoice_lines il
|
||||||
|
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{ key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' },
|
||||||
|
{ key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' },
|
||||||
|
{ key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' },
|
||||||
|
{ key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' },
|
||||||
|
{ key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
// eims_ack_date is varchar in the schema, not a timestamp.
|
||||||
|
{ key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' },
|
||||||
|
{ key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' },
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'issued', label: 'Issued', type: 'daterange' },
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||||
|
// The invoices list page sends a single `status`; accept both so its
|
||||||
|
// on-screen filter actually carries into the export.
|
||||||
|
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||||
|
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||||
|
{ value: 'ETB', label: 'ETB' },
|
||||||
|
{ value: 'USD', label: 'USD' },
|
||||||
|
] },
|
||||||
|
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
qb.andWhere('i.deleted_at IS NULL');
|
||||||
|
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
|
||||||
|
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||||
|
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||||
|
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency });
|
||||||
|
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||||
|
}
|
||||||
|
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||||
|
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ['AVAILABLE', 'IN_USE', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: v.replace(/_/g, ' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const locomotivesDataset: ExportDataset = {
|
||||||
|
key: 'locomotives',
|
||||||
|
title: 'Locomotives',
|
||||||
|
description: 'Locomotive fleet with capacity, traction specs and current location',
|
||||||
|
group: 'Fleet',
|
||||||
|
permission: FREIGHT_PERMS.locomotives.view,
|
||||||
|
base: { entity: Locomotive, alias: 'l' },
|
||||||
|
|
||||||
|
joins: [{ alias: 'y', entity: Yard, on: 'y.id = l.current_yard_id' }],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'identity', label: 'Locomotive' },
|
||||||
|
{ id: 'capacity', label: 'Capacity & traction' },
|
||||||
|
{ id: 'location', label: 'Status & location' },
|
||||||
|
{ id: 'assignment', label: 'Assignment' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'code', label: 'Code', type: 'string', group: 'identity', default: true, select: 'l.code', sortExpr: 'l.code' },
|
||||||
|
{ key: 'name', label: 'Name', type: 'string', group: 'identity', default: true, select: 'l.name' },
|
||||||
|
{ key: 'locomotiveType', label: 'Type', type: 'string', group: 'identity', default: true, select: 'l.locomotive_type' },
|
||||||
|
{ key: 'createdAt', label: 'Added', type: 'date', group: 'identity', select: `to_char(l.created_at, 'YYYY-MM-DD')`, sortExpr: 'l.created_at' },
|
||||||
|
|
||||||
|
{ key: 'maxPullWeightTons', label: 'Max pull weight (t)', type: 'tons', group: 'capacity', default: true, select: 'l.max_pull_weight_tons::float8', sortExpr: 'l.max_pull_weight_tons' },
|
||||||
|
{ key: 'maxTrainLengthMeters', label: 'Max train length (m)', type: 'number', group: 'capacity', default: true, select: 'l.max_train_length_meters::float8' },
|
||||||
|
{ key: 'overageToleranceTons', label: 'Overage tolerance (t)', type: 'tons', group: 'capacity', select: 'l.overage_tolerance_tons::float8' },
|
||||||
|
{ key: 'overageToleranceMeters', label: 'Overage tolerance (m)', type: 'number', group: 'capacity', select: 'l.overage_tolerance_meters::float8' },
|
||||||
|
{ key: 'powerKw', label: 'Power (kW)', type: 'number', group: 'capacity', select: 'l.power_kw::float8' },
|
||||||
|
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number', group: 'capacity', select: 'l.traction_force_kn::float8' },
|
||||||
|
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number', group: 'capacity', select: 'l.max_speed_kmh::float8' },
|
||||||
|
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'location', default: true, select: 'l.status', sortExpr: 'l.status' },
|
||||||
|
{ key: 'availableFrom', label: 'Available from', type: 'date', group: 'location', select: `to_char(l.available_from, 'YYYY-MM-DD')` },
|
||||||
|
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
|
||||||
|
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
|
||||||
|
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
|
||||||
|
|
||||||
|
{
|
||||||
|
// One-to-many -> aggregate in a subquery, never a join.
|
||||||
|
key: 'assignedTrains', label: 'Assigned trains', type: 'string', group: 'assignment',
|
||||||
|
select: `(SELECT string_agg(DISTINCT tr.code, ' | ')
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id AND ts.deleted_at IS NULL
|
||||||
|
JOIN freight.trains tr ON tr.id = ts.train_id AND tr.deleted_at IS NULL
|
||||||
|
WHERE tsl.locomotive_id = l.id AND tsl.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS },
|
||||||
|
{ key: 'locomotiveType', label: 'Type', type: 'text' },
|
||||||
|
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search code or name', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'code', dir: 'ASC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params } = ctx;
|
||||||
|
qb.andWhere('l.deleted_at IS NULL');
|
||||||
|
if (params.status) qb.andWhere('l.status = :status', { status: params.status });
|
||||||
|
if (params.locomotiveType) qb.andWhere('l.locomotive_type = :locomotiveType', { locomotiveType: params.locomotiveType });
|
||||||
|
if (params.currentYardId) qb.andWhere('l.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(l.code ILIKE :search OR l.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||||
|
}
|
||||||
|
// Locomotives carry no trade direction — nothing to scope. Intentional.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { PaymentEntity } from '../../payment/entities/payment.entity';
|
||||||
|
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `freight.payments` breaks two conventions this codebase otherwise holds to,
|
||||||
|
* both verified against the live schema:
|
||||||
|
*
|
||||||
|
* 1. It does NOT extend @edr/api-common's BaseEntity — there is no
|
||||||
|
* `updated_at` and no `deleted_at`. A soft-delete guard here is a 42703,
|
||||||
|
* which is why `scope()` below applies the ACL only.
|
||||||
|
* 2. The failure columns are `failer_code` / `failer_message`, not `failure_*`.
|
||||||
|
*
|
||||||
|
* Raw gateway payloads (`raw_initiation`, `client_action`) are deliberately
|
||||||
|
* not exposed as fields.
|
||||||
|
*/
|
||||||
|
export const paymentsDataset: ExportDataset = {
|
||||||
|
key: 'payments',
|
||||||
|
title: 'Payments',
|
||||||
|
description: 'Payment transactions with method, status, and the booking and customer they belong to',
|
||||||
|
group: 'Finance',
|
||||||
|
permission: FREIGHT_PERMS.payments.view,
|
||||||
|
base: { entity: PaymentEntity, alias: 'p' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
// ref_id is a varchar pointer at the booking, so the cast is required.
|
||||||
|
{ alias: 'bk', entity: Booking, on: 'bk.id::text = p.ref_id' },
|
||||||
|
{ alias: 'c', entity: Company, on: 'c.id = bk.company_id', requires: ['bk'] },
|
||||||
|
],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'payment', label: 'Payment' },
|
||||||
|
{ id: 'amounts', label: 'Amounts' },
|
||||||
|
{ id: 'gateway', label: 'Gateway' },
|
||||||
|
{ id: 'booking', label: 'Booking' },
|
||||||
|
{ id: 'customer', label: 'Customer' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'merchantOrderId', label: 'Order ID', type: 'string', group: 'payment', default: true, select: 'p.merchant_order_id', sortExpr: 'p.merchant_order_id' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'payment', default: true, select: 'p.status', sortExpr: 'p.status' },
|
||||||
|
{ key: 'method', label: 'Method', type: 'string', group: 'payment', default: true, select: 'p.method', sortExpr: 'p.method' },
|
||||||
|
{ key: 'type', label: 'Type', type: 'string', group: 'payment', select: 'p.type' },
|
||||||
|
{ key: 'referenceType', label: 'Reference type', type: 'string', group: 'payment', select: 'p.reference_type' },
|
||||||
|
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'payment', default: true, select: `to_char(p.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.created_at' },
|
||||||
|
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', default: true, select: `to_char(p.paid_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.paid_at' },
|
||||||
|
{ key: 'refundedAt', label: 'Refunded at', type: 'datetime', group: 'payment', select: `to_char(p.refunded_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'expiresAt', label: 'Expires at', type: 'datetime', group: 'payment', select: `to_char(p.expires_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
|
||||||
|
{ key: 'amount', label: 'Amount', type: 'money', group: 'amounts', default: true, select: 'p.amount::float8', sortExpr: 'p.amount' },
|
||||||
|
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'p.currency' },
|
||||||
|
{
|
||||||
|
// payment_refunds stores MINOR units (amount_minor), unlike payments.amount
|
||||||
|
// which is major. Divide, or a 50.00 refund exports as 5000.
|
||||||
|
key: 'refundedTotal', label: 'Refunded total', type: 'money', group: 'amounts',
|
||||||
|
select: `(SELECT ROUND(COALESCE(SUM(pr.amount_minor), 0) / 100.0, 2)::float8
|
||||||
|
FROM freight.payment_refunds pr WHERE pr.payment_id = p.id)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{ key: 'transactionId', label: 'Transaction ID', type: 'string', group: 'gateway', select: 'p.transaction_id' },
|
||||||
|
{ key: 'failerCode', label: 'Failure code', type: 'string', group: 'gateway', select: 'p.failer_code' },
|
||||||
|
{ key: 'failureMessage', label: 'Failure message', type: 'string', group: 'gateway', select: 'p.failer_message' },
|
||||||
|
{ key: 'reason', label: 'Reason', type: 'string', group: 'gateway', select: 'p.reason' },
|
||||||
|
|
||||||
|
{ key: 'bookingReference', label: 'Booking', type: 'string', group: 'booking', default: true, requires: ['bk'], select: 'bk.reference' },
|
||||||
|
{ key: 'bookingStatus', label: 'Booking status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.status' },
|
||||||
|
{ key: 'bookingPaymentStatus', label: 'Booking payment status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.payment_status' },
|
||||||
|
{ key: 'bookingTradeDirection', label: 'Direction', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.trade_direction' },
|
||||||
|
{ key: 'bookingFreightType', label: 'Freight type', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.freight_type' },
|
||||||
|
{ key: 'bookingPnrCode', label: 'PNR code', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.pnr_code' },
|
||||||
|
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name' },
|
||||||
|
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||||
|
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||||
|
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'select', options: [
|
||||||
|
'action-required', 'processing', 'success', 'failed', 'canceled', 'refunded',
|
||||||
|
].map((v) => ({ value: v, label: v })) },
|
||||||
|
{ key: 'method', label: 'Method', type: 'select', options: [
|
||||||
|
'telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill',
|
||||||
|
].map((v) => ({ value: v, label: v })) },
|
||||||
|
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||||
|
{ value: 'ETB', label: 'ETB' },
|
||||||
|
{ value: 'USD', label: 'USD' },
|
||||||
|
] },
|
||||||
|
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
// No `p.deleted_at IS NULL` — this table has no soft-delete column.
|
||||||
|
if (params.createdFrom) qb.andWhere('p.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||||
|
if (params.createdTo) qb.andWhere('p.created_at < :createdTo', { createdTo: params.createdTo });
|
||||||
|
if (params.status) qb.andWhere('p.status = :status', { status: params.status });
|
||||||
|
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
|
||||||
|
if (params.currency) qb.andWhere('p.currency = :currency', { currency: params.currency });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(p.merchant_order_id ILIKE :search OR p.transaction_id ILIKE :search)', {
|
||||||
|
search: `%${params.search as string}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyBookingRefDirectionScope(qb, 'p.ref_id', directions);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Route } from '../../routes/entities/route.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `freightType` is NOT a column on train_schedules — it is derived from the
|
||||||
|
* bookings aboard (the list service does this with an EXISTS subquery). Exposed
|
||||||
|
* here as an aggregate over those bookings, never as `sch.freight_type`.
|
||||||
|
*/
|
||||||
|
export const trainSchedulesDataset: ExportDataset = {
|
||||||
|
key: 'train-schedules',
|
||||||
|
title: 'Train schedules',
|
||||||
|
description: 'Scheduled trains with route, timings, booking window and load',
|
||||||
|
group: 'Operations',
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
base: { entity: TrainSchedule, alias: 'sch' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
{ alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' },
|
||||||
|
{ alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' },
|
||||||
|
{ alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' },
|
||||||
|
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' },
|
||||||
|
],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'schedule', label: 'Schedule' },
|
||||||
|
{ id: 'route', label: 'Route' },
|
||||||
|
{ id: 'timings', label: 'Timings' },
|
||||||
|
{ id: 'window', label: 'Booking window' },
|
||||||
|
{ id: 'load', label: 'Load' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' },
|
||||||
|
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' },
|
||||||
|
{ key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' },
|
||||||
|
{ key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' },
|
||||||
|
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' },
|
||||||
|
{ key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' },
|
||||||
|
{ key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' },
|
||||||
|
{ key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' },
|
||||||
|
|
||||||
|
{ key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' },
|
||||||
|
{ key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' },
|
||||||
|
{ key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' },
|
||||||
|
{ key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' },
|
||||||
|
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
|
||||||
|
|
||||||
|
{ key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' },
|
||||||
|
{ key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{
|
||||||
|
key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings',
|
||||||
|
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings',
|
||||||
|
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{ key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' },
|
||||||
|
{ key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' },
|
||||||
|
{ key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` },
|
||||||
|
{ key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' },
|
||||||
|
|
||||||
|
{ key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' },
|
||||||
|
{
|
||||||
|
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true,
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||||
|
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Derived, not a column — see the file header.
|
||||||
|
key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true,
|
||||||
|
select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b
|
||||||
|
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
|
||||||
|
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.wagons w
|
||||||
|
WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'departure', label: 'Departure', type: 'daterange' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'text' },
|
||||||
|
{ key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
|
||||||
|
// freightType is derived from the bookings aboard, so it filters via
|
||||||
|
// EXISTS — the same shape the list service's scheduleFreightTypeFilter uses.
|
||||||
|
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
|
||||||
|
{ key: 'originStationId', label: 'Origin', type: 'text' },
|
||||||
|
{ key: 'destinationStationId', label: 'Destination', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search reference or train number', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
qb.andWhere('sch.deleted_at IS NULL');
|
||||||
|
if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom });
|
||||||
|
if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo });
|
||||||
|
if (params.status) qb.andWhere('sch.status = :status', { status: params.status });
|
||||||
|
if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction });
|
||||||
|
if (params.freightType) {
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (SELECT 1 FROM freight.bookings fb
|
||||||
|
WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL
|
||||||
|
AND fb.freight_type = :freightType)`,
|
||||||
|
{ freightType: params.freightType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId });
|
||||||
|
if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` });
|
||||||
|
}
|
||||||
|
// Schedules carry their own `direction` column, so scope on that directly
|
||||||
|
// rather than through the bookings aboard.
|
||||||
|
applyDirectionScope(qb, 'sch.direction', directions);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Route } from '../../routes/entities/route.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list endpoint (`trains.service.ts findAll`) loads NO relations, so the
|
||||||
|
* UI shows raw FK uuids where names belong. This dataset resolves them, which
|
||||||
|
* makes it the clearest "the export shows more than the screen" case in the set.
|
||||||
|
*/
|
||||||
|
export const trainsDataset: ExportDataset = {
|
||||||
|
key: 'trains',
|
||||||
|
title: 'Trains',
|
||||||
|
description: 'Trains with route, stations, capacity and current composition',
|
||||||
|
group: 'Fleet',
|
||||||
|
permission: FREIGHT_PERMS.trains.view,
|
||||||
|
base: { entity: Train, alias: 't' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
{ alias: 'y', entity: Yard, on: 'y.id = t.current_yard_id' },
|
||||||
|
{ alias: 'rt', entity: Route, on: 'rt.id = t.route_id' },
|
||||||
|
{ alias: 'os', entity: Yard, on: 'os.id = t.origin_station_id' },
|
||||||
|
{ alias: 'ds', entity: Yard, on: 'ds.id = t.destination_station_id' },
|
||||||
|
],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'train', label: 'Train' },
|
||||||
|
{ id: 'route', label: 'Route & stations' },
|
||||||
|
{ id: 'capacity', label: 'Capacity' },
|
||||||
|
{ id: 'composition', label: 'Composition' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'code', label: 'Code', type: 'string', group: 'train', default: true, select: 't.code', sortExpr: 't.code' },
|
||||||
|
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'train', default: true, select: 't.train_number' },
|
||||||
|
{ key: 'trainName', label: 'Train name', type: 'string', group: 'train', default: true, select: 't.train_name' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'train', default: true, select: 't.status', sortExpr: 't.status' },
|
||||||
|
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'train', select: 't.import_train_number' },
|
||||||
|
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'train', select: 't.export_train_number' },
|
||||||
|
{ key: 'locomotiveNumber', label: 'Locomotive number', type: 'string', group: 'train', select: 't.locomotive_number' },
|
||||||
|
{ key: 'notes', label: 'Notes', type: 'string', group: 'train', select: 't.notes' },
|
||||||
|
{ key: 'remarks', label: 'Remarks', type: 'string', group: 'train', select: 't.remarks' },
|
||||||
|
{ key: 'createdAt', label: 'Added', type: 'date', group: 'train', select: `to_char(t.created_at, 'YYYY-MM-DD')`, sortExpr: 't.created_at' },
|
||||||
|
|
||||||
|
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'route', default: true, requires: ['y'], select: 'y.label' },
|
||||||
|
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'route', requires: ['y'], select: 'y.code' },
|
||||||
|
{ key: 'routeDirection', label: 'Route direction', type: 'string', group: 'route', requires: ['rt'], select: 'rt.direction' },
|
||||||
|
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
|
||||||
|
{ key: 'originStation', label: 'Origin station', type: 'string', group: 'route', requires: ['os'], select: 'os.label' },
|
||||||
|
{ key: 'destinationStation', label: 'Destination station', type: 'string', group: 'route', requires: ['ds'], select: 'ds.label' },
|
||||||
|
{ key: 'departureTime', label: 'Departure time', type: 'string', group: 'route', select: 't.departure_time::text' },
|
||||||
|
{ key: 'arrivalTime', label: 'Arrival time', type: 'string', group: 'route', select: 't.arrival_time::text' },
|
||||||
|
|
||||||
|
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'capacity', default: true, select: 't.capacity_tons::float8', sortExpr: 't.capacity_tons' },
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'wagonCount', label: 'Wagons attached', type: 'number', group: 'composition', default: true,
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.wagons w
|
||||||
|
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wagonNumbers', label: 'Wagon numbers', type: 'string', group: 'composition',
|
||||||
|
select: `(SELECT string_agg(w.wagon_number, ' | ' ORDER BY w.sequence_number NULLS LAST, w.wagon_number)
|
||||||
|
FROM freight.wagons w
|
||||||
|
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'status', label: 'Status', type: 'text' },
|
||||||
|
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search code, number or name', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'code', dir: 'ASC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params } = ctx;
|
||||||
|
qb.andWhere('t.deleted_at IS NULL');
|
||||||
|
if (params.status) qb.andWhere('t.status = :status', { status: params.status });
|
||||||
|
if (params.currentYardId) qb.andWhere('t.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(t.code ILIKE :search OR t.train_number ILIKE :search OR t.train_name ILIKE :search)', {
|
||||||
|
search: `%${params.search as string}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Trains carry no trade direction — nothing to scope. Intentional.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
|
import { ExportDataset } from '../export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two traps this dataset works around:
|
||||||
|
*
|
||||||
|
* 1. Tare weight, payload and length live on WAGON_TYPE, not wagon — which is
|
||||||
|
* why they carry `requires: ['wt']` and their sortExpr points at `wt`.
|
||||||
|
* 2. `lastMaintenanceAt` / `lastAvailableAt` come from a grouped query over
|
||||||
|
* wagon_status_logs in the service (`attachStatusDates`). Ported here as
|
||||||
|
* correlated subqueries so they compose with everything else and cost
|
||||||
|
* nothing when unticked. Note the log columns are `from_status`/`to_status`,
|
||||||
|
* not `status`.
|
||||||
|
*/
|
||||||
|
export const wagonsDataset: ExportDataset = {
|
||||||
|
key: 'wagons',
|
||||||
|
title: 'Wagons',
|
||||||
|
description: 'Wagon fleet with type specs, location, train assignment and status history',
|
||||||
|
group: 'Fleet',
|
||||||
|
permission: FREIGHT_PERMS.wagons.view,
|
||||||
|
base: { entity: Wagon, alias: 'w' },
|
||||||
|
|
||||||
|
joins: [
|
||||||
|
{ alias: 'wt', entity: WagonType, on: 'wt.id = w.wagon_type_id' },
|
||||||
|
{ alias: 'y', entity: Yard, on: 'y.id = w.current_yard_id' },
|
||||||
|
{ alias: 't', entity: Train, on: 't.id = w.train_id' },
|
||||||
|
{ alias: 'sch', entity: TrainSchedule, on: 'sch.id = w.current_train_schedule_id' },
|
||||||
|
],
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{ id: 'wagon', label: 'Wagon' },
|
||||||
|
{ id: 'type', label: 'Type & specs' },
|
||||||
|
{ id: 'location', label: 'Location' },
|
||||||
|
{ id: 'assignment', label: 'Assignment' },
|
||||||
|
{ id: 'history', label: 'Status history' },
|
||||||
|
],
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
{ key: 'wagonNumber', label: 'Wagon number', type: 'string', group: 'wagon', default: true, select: 'w.wagon_number', sortExpr: 'w.wagon_number' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', group: 'wagon', default: true, select: 'w.status', sortExpr: 'w.status' },
|
||||||
|
{ key: 'sequenceNumber', label: 'Sequence no.', type: 'number', group: 'wagon', select: 'w.sequence_number' },
|
||||||
|
{ key: 'notes', label: 'Notes', type: 'string', group: 'wagon', select: 'w.notes' },
|
||||||
|
{ key: 'createdAt', label: 'Added', type: 'date', group: 'wagon', select: `to_char(w.created_at, 'YYYY-MM-DD')`, sortExpr: 'w.created_at' },
|
||||||
|
|
||||||
|
{ key: 'wagonType', label: 'Type', type: 'string', group: 'type', default: true, requires: ['wt'], select: 'wt.name', sortExpr: 'wt.name' },
|
||||||
|
{ key: 'wagonTypeCode', label: 'Type code', type: 'string', group: 'type', requires: ['wt'], select: 'wt.code' },
|
||||||
|
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'type', default: true, requires: ['wt'], select: 'wt.capacity_tons::float8', sortExpr: 'wt.capacity_tons' },
|
||||||
|
{ key: 'tareWeightTons', label: 'Tare weight (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.tare_weight_tons::float8' },
|
||||||
|
{ key: 'lengthMeters', label: 'Length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.length_meters::float8' },
|
||||||
|
{ key: 'equatedLengthM', label: 'Equated length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.equated_length_m::float8' },
|
||||||
|
{ key: 'maxContainerGrossT', label: 'Max container gross (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.max_container_gross_t::float8' },
|
||||||
|
{ key: 'supportsContainer', label: 'Supports container', type: 'boolean', group: 'type', requires: ['wt'], select: 'wt.supports_container' },
|
||||||
|
{ key: 'supportedLoadTypes', label: 'Supported load types', type: 'string', group: 'type', requires: ['wt'], select: 'wt.supported_load_types::text' },
|
||||||
|
|
||||||
|
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
|
||||||
|
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
|
||||||
|
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
|
||||||
|
|
||||||
|
{ key: 'trainCode', label: 'Train', type: 'string', group: 'assignment', default: true, requires: ['t'], select: 't.code' },
|
||||||
|
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'assignment', requires: ['t'], select: 't.train_number' },
|
||||||
|
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'assignment', select: 'w.export_train_number' },
|
||||||
|
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'assignment', select: 'w.import_train_number' },
|
||||||
|
{ key: 'scheduleReference', label: 'Current schedule', type: 'string', group: 'assignment', requires: ['sch'], select: 'sch.reference' },
|
||||||
|
{ key: 'scheduleDeparture', label: 'Schedule departure', type: 'date', group: 'assignment', requires: ['sch'], select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD')` },
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'lastMaintenanceAt', label: 'Last maintenance', type: 'datetime', group: 'history',
|
||||||
|
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
|
||||||
|
FROM freight.wagon_status_logs l
|
||||||
|
WHERE l.wagon_id = w.id AND l.to_status = 'MAINTENANCE' AND l.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastAvailableAt', label: 'Last available', type: 'datetime', group: 'history',
|
||||||
|
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
|
||||||
|
FROM freight.wagon_status_logs l
|
||||||
|
WHERE l.wagon_id = w.id AND l.to_status = 'AVAILABLE' AND l.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statusChangeCount', label: 'Status changes', type: 'number', group: 'history',
|
||||||
|
select: `(SELECT COUNT(*)::int FROM freight.wagon_status_logs l
|
||||||
|
WHERE l.wagon_id = w.id AND l.deleted_at IS NULL)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
filters: [
|
||||||
|
{ key: 'status', label: 'Status', type: 'text' },
|
||||||
|
{ key: 'wagonTypeId', label: 'Wagon type', type: 'text' },
|
||||||
|
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||||
|
{ key: 'trainId', label: 'Train', type: 'text' },
|
||||||
|
{ key: 'search', label: 'Search wagon number', type: 'text' },
|
||||||
|
],
|
||||||
|
|
||||||
|
defaultSort: { key: 'wagonNumber', dir: 'ASC' },
|
||||||
|
|
||||||
|
scope(ctx, qb) {
|
||||||
|
const { params } = ctx;
|
||||||
|
qb.andWhere('w.deleted_at IS NULL');
|
||||||
|
if (params.status) qb.andWhere('w.status = :status', { status: params.status });
|
||||||
|
if (params.wagonTypeId) qb.andWhere('w.wagon_type_id = :wagonTypeId', { wagonTypeId: params.wagonTypeId });
|
||||||
|
if (params.currentYardId) qb.andWhere('w.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||||
|
if (params.trainId) qb.andWhere('w.train_id = :trainId', { trainId: params.trainId });
|
||||||
|
if (params.search) qb.andWhere('w.wagon_number ILIKE :search', { search: `%${params.search as string}%` });
|
||||||
|
// Wagons carry no trade direction — nothing to scope. Intentional.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||||
|
|
||||||
|
export interface ExportFilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportFilterDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ExportFilterType;
|
||||||
|
/** Static choices. Mutually exclusive with `optionsQuery`. */
|
||||||
|
options?: ExportFilterOption[];
|
||||||
|
/** Reference-data choices resolved from the DB and cached for the process. */
|
||||||
|
optionsQuery?: (ds: DataSource) => Promise<ExportFilterOption[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */
|
||||||
|
export type RawFilterQuery = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce raw query strings into typed filter params per a filter declaration
|
||||||
|
* list. Unknown keys are dropped rather than rejected.
|
||||||
|
*
|
||||||
|
* Shared by the report runner and the export runner so the `daterange`
|
||||||
|
* handling in particular cannot drift between them: `To` is pushed forward a
|
||||||
|
* day because callers mean an INCLUSIVE end date while the SQL bound is
|
||||||
|
* exclusive (`created_at < :dateTo`).
|
||||||
|
*/
|
||||||
|
export function coerceFilterParams(
|
||||||
|
filters: ExportFilterDef[],
|
||||||
|
raw: RawFilterQuery,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
for (const filter of filters) {
|
||||||
|
if (filter.type === 'daterange') {
|
||||||
|
const from = raw[`${filter.key}From`];
|
||||||
|
const to = raw[`${filter.key}To`];
|
||||||
|
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
|
||||||
|
params[`${filter.key}To`] = to
|
||||||
|
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
|
||||||
|
: null;
|
||||||
|
} else if (filter.type === 'multiselect') {
|
||||||
|
const csv = raw[filter.key];
|
||||||
|
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||||
|
params[filter.key] = items.length ? items : null;
|
||||||
|
} else {
|
||||||
|
params[filter.key] = raw[filter.key]?.trim() || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-lifetime cache for `optionsQuery` results — small, rarely-changing
|
||||||
|
* reference lists (23 stations, 18 cargo types) hit on every catalog load.
|
||||||
|
*
|
||||||
|
* ponytail: keyed by filter key alone, so two registries sharing a filter key
|
||||||
|
* share one option list. Key by `${registry}:${filterKey}` if that ever bites.
|
||||||
|
*/
|
||||||
|
const optionsCache = new Map<string, ExportFilterOption[]>();
|
||||||
|
|
||||||
|
export async function resolveFilterOptions(
|
||||||
|
filters: ExportFilterDef[],
|
||||||
|
ds: DataSource,
|
||||||
|
): Promise<ExportFilterDef[]> {
|
||||||
|
return Promise.all(
|
||||||
|
filters.map(async (filter) => {
|
||||||
|
if (!filter.optionsQuery) return filter;
|
||||||
|
const cached = optionsCache.get(filter.key);
|
||||||
|
if (cached) return { ...filter, options: cached, optionsQuery: undefined };
|
||||||
|
const options = await filter.optionsQuery(ds);
|
||||||
|
optionsCache.set(filter.key, options);
|
||||||
|
return { ...filter, options, optionsQuery: undefined };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { resolveJoins } from './export-query.builder';
|
||||||
|
import { ExportDataset, ExportField } from './export.types';
|
||||||
|
|
||||||
|
const field = (key: string, requires?: string[]): ExportField => ({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
type: 'string',
|
||||||
|
group: 'g',
|
||||||
|
select: `x.${key}`,
|
||||||
|
requires,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */
|
||||||
|
const entity = {} as ExportDataset['joins'][number]['entity'];
|
||||||
|
|
||||||
|
const dataset = (
|
||||||
|
joins: ExportDataset['joins'],
|
||||||
|
alwaysJoin?: string[],
|
||||||
|
): ExportDataset =>
|
||||||
|
({
|
||||||
|
key: 'test',
|
||||||
|
joins,
|
||||||
|
alwaysJoin,
|
||||||
|
fields: [],
|
||||||
|
}) as unknown as ExportDataset;
|
||||||
|
|
||||||
|
describe('resolveJoins', () => {
|
||||||
|
it('pulls in only the joins the selected fields ask for', () => {
|
||||||
|
const ds = dataset([
|
||||||
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||||
|
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
||||||
|
]);
|
||||||
|
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting nothing still applies alwaysJoin — the count query relies on this', () => {
|
||||||
|
const ds = dataset(
|
||||||
|
[
|
||||||
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||||
|
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
||||||
|
],
|
||||||
|
['a'],
|
||||||
|
);
|
||||||
|
expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a transitive dependency, dependency first', () => {
|
||||||
|
const ds = dataset([
|
||||||
|
{ alias: 'ct', entity, on: 'ct.id = b.contract_id' },
|
||||||
|
{ alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
||||||
|
]);
|
||||||
|
expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a multi-hop chain in order', () => {
|
||||||
|
const ds = dataset([
|
||||||
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||||
|
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
||||||
|
{ alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] },
|
||||||
|
]);
|
||||||
|
expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a shared join once, not per field that needs it', () => {
|
||||||
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||||
|
const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]);
|
||||||
|
expect(joins.map((j) => j.alias)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate a join already pulled in by alwaysJoin', () => {
|
||||||
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']);
|
||||||
|
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a cycle rather than looping forever', () => {
|
||||||
|
const ds = dataset([
|
||||||
|
{ alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] },
|
||||||
|
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
||||||
|
]);
|
||||||
|
expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => {
|
||||||
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||||
|
expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a field with no requires pulls in no joins at all', () => {
|
||||||
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||||
|
expect(resolveJoins(ds, [field('plain')])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal file
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { ExportContext, ExportDataset, ExportField, ExportJoin } from './export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort expression fallback: the SELECT alias TypeORM emitted, quoted. TypeORM
|
||||||
|
* double-quotes `addSelect` aliases (preserving case), so ordering by the bare
|
||||||
|
* key lets Postgres fold it to lowercase and 42703 on any camelCase alias.
|
||||||
|
*/
|
||||||
|
export const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selected fields -> the joins they need, transitively, dependencies first.
|
||||||
|
* DFS post-order over `requires`, memoized. Deterministic: `alwaysJoin` first,
|
||||||
|
* then fields in the dataset's own declaration order.
|
||||||
|
*/
|
||||||
|
export function resolveJoins(dataset: ExportDataset, fields: ExportField[]): ExportJoin[] {
|
||||||
|
const byAlias = new Map(dataset.joins.map((j) => [j.alias, j]));
|
||||||
|
const out: ExportJoin[] = [];
|
||||||
|
const done = new Set<string>();
|
||||||
|
const onStack = new Set<string>();
|
||||||
|
|
||||||
|
const visit = (alias: string): void => {
|
||||||
|
if (done.has(alias)) return;
|
||||||
|
if (onStack.has(alias)) {
|
||||||
|
throw new Error(`export "${dataset.key}": join cycle at alias "${alias}"`);
|
||||||
|
}
|
||||||
|
const join = byAlias.get(alias);
|
||||||
|
if (!join) {
|
||||||
|
throw new Error(`export "${dataset.key}": unknown join alias "${alias}"`);
|
||||||
|
}
|
||||||
|
onStack.add(alias);
|
||||||
|
for (const dep of join.requires ?? []) visit(dep);
|
||||||
|
onStack.delete(alias);
|
||||||
|
done.add(alias);
|
||||||
|
out.push(join);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const alias of dataset.alwaysJoin ?? []) visit(alias);
|
||||||
|
for (const field of fields) for (const alias of field.requires ?? []) visit(alias);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The download query: base + only the joins the selected fields need. */
|
||||||
|
export function buildExportQuery(
|
||||||
|
dataset: ExportDataset,
|
||||||
|
fields: ExportField[],
|
||||||
|
ctx: ExportContext,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = ctx.ds.createQueryBuilder().from(dataset.base.entity, dataset.base.alias);
|
||||||
|
for (const join of resolveJoins(dataset, fields)) {
|
||||||
|
qb.leftJoin(join.entity, join.alias, join.on);
|
||||||
|
}
|
||||||
|
for (const field of fields) qb.addSelect(field.select, field.key);
|
||||||
|
dataset.scope(ctx, qb);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count query: same base, same `scope()`, same WHERE — but no field joins
|
||||||
|
* and no selects. Exact rather than an estimate, because every lazy join is a
|
||||||
|
* left join to a to-one side and so cannot change the row count.
|
||||||
|
*/
|
||||||
|
export function buildExportCountQuery(
|
||||||
|
dataset: ExportDataset,
|
||||||
|
ctx: ExportContext,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('COUNT(*)::int', 'total')
|
||||||
|
.from(dataset.base.entity, dataset.base.alias);
|
||||||
|
for (const join of resolveJoins(dataset, [])) {
|
||||||
|
qb.leftJoin(join.entity, join.alias, join.on);
|
||||||
|
}
|
||||||
|
dataset.scope(ctx, qb);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a requested sort against the SELECTED fields. Restricting to selected
|
||||||
|
* fields means a sort can never pull in a join the projection didn't already
|
||||||
|
* need — which is what keeps the count query's join set correct.
|
||||||
|
*/
|
||||||
|
export function resolveExportSort(
|
||||||
|
dataset: ExportDataset,
|
||||||
|
fields: ExportField[],
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: string,
|
||||||
|
): { expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||||
|
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
const requested = sortBy && fields.find((f) => f.key === sortBy && f.sortExpr);
|
||||||
|
if (requested) return { expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
||||||
|
|
||||||
|
if (!dataset.defaultSort) return null;
|
||||||
|
const fallback = fields.find((f) => f.key === dataset.defaultSort!.key);
|
||||||
|
if (!fallback) return null;
|
||||||
|
return {
|
||||||
|
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
|
||||||
|
dir: dataset.defaultSort.dir,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
EXPORT_MIME,
|
||||||
|
formatRowCap,
|
||||||
|
pickByKey,
|
||||||
|
resolveExportFormat,
|
||||||
|
resolveRowLimit,
|
||||||
|
} from './export-request.util';
|
||||||
|
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
|
||||||
|
|
||||||
|
describe('resolveExportFormat', () => {
|
||||||
|
it('only \'pdf\' exports as pdf', () => {
|
||||||
|
expect(resolveExportFormat('pdf')).toBe('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('\'csv\' exports as csv', () => {
|
||||||
|
expect(resolveExportFormat('csv')).toBe('csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, 'xlsx', 'doc', ''])('%p falls back to xlsx', (raw) => {
|
||||||
|
expect(resolveExportFormat(raw)).toBe('xlsx');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatRowCap', () => {
|
||||||
|
it('is the format\'s hard ceiling and is not caller-controllable', () => {
|
||||||
|
expect(formatRowCap('xlsx')).toBe(XLSX_ROW_CAP);
|
||||||
|
expect(formatRowCap('csv')).toBe(CSV_ROW_CAP);
|
||||||
|
expect(formatRowCap('pdf')).toBe(PDF_ROW_CAP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRowLimit', () => {
|
||||||
|
it('no limit means "everything, up to the cap"', () => {
|
||||||
|
expect(resolveRowLimit('xlsx', undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an explicit limit is the caller asking to be truncated — kept as-is', () => {
|
||||||
|
// Distinct from the cap: 100 here must yield 100 rows, not a 400, even
|
||||||
|
// when the unfiltered result is far larger.
|
||||||
|
expect(resolveRowLimit('pdf', '100')).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an explicit limit over the format cap is clamped down', () => {
|
||||||
|
expect(resolveRowLimit('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
|
||||||
|
expect(resolveRowLimit('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_ROW_CAP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p means no limit', (raw) => {
|
||||||
|
expect(resolveRowLimit('xlsx', raw)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EXPORT_MIME', () => {
|
||||||
|
it('every format has a content type and a matching extension', () => {
|
||||||
|
expect(EXPORT_MIME.csv.ext).toBe('csv');
|
||||||
|
expect(EXPORT_MIME.xlsx.ext).toBe('xlsx');
|
||||||
|
expect(EXPORT_MIME.pdf.type).toBe('application/pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickByKey', () => {
|
||||||
|
const columns = [
|
||||||
|
{ key: 'a', label: 'A', type: 'string' as const },
|
||||||
|
{ key: 'b', label: 'B', type: 'number' as const },
|
||||||
|
{ key: 'c', label: 'C', type: 'money' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('missing fields returns every column', () => {
|
||||||
|
expect(pickByKey(columns, undefined)).toEqual(columns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty fields string returns every column', () => {
|
||||||
|
expect(pickByKey(columns, '')).toEqual(columns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a known subset filters to just those, in the source\'s own order', () => {
|
||||||
|
expect(pickByKey(columns, 'c,a')).toEqual([columns[0], columns[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown keys are dropped, not passed through', () => {
|
||||||
|
expect(pickByKey(columns, 'a,ghost')).toEqual([columns[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-unknown keys falls back to everything instead of a blank sheet', () => {
|
||||||
|
expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
|
||||||
|
|
||||||
|
export type ExportFormat = 'xlsx' | 'csv' | 'pdf';
|
||||||
|
|
||||||
|
/** Anything but the literal 'pdf' or 'csv' exports as xlsx. */
|
||||||
|
export function resolveExportFormat(raw: string | undefined): ExportFormat {
|
||||||
|
if (raw === 'pdf') return 'pdf';
|
||||||
|
if (raw === 'csv') return 'csv';
|
||||||
|
return 'xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format's hard ceiling. Not caller-controllable: exceeding it is an error,
|
||||||
|
* because a silently short file is worse than a clear failure.
|
||||||
|
*/
|
||||||
|
export function formatRowCap(format: ExportFormat): number {
|
||||||
|
return format === 'pdf' ? PDF_ROW_CAP : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's deliberate "just the first N rows", clamped to the format cap.
|
||||||
|
* `undefined` means "everything, up to the cap".
|
||||||
|
*
|
||||||
|
* This is a DIFFERENT thing from the cap and must not share a number with it.
|
||||||
|
* Conflating them (as this code did originally) makes the dialog's
|
||||||
|
* "Records: First 100" option fail outright on any export with more than 100
|
||||||
|
* rows — the user explicitly asked to be truncated, so truncating is the
|
||||||
|
* correct answer, not a 400.
|
||||||
|
*/
|
||||||
|
export function resolveRowLimit(
|
||||||
|
format: ExportFormat,
|
||||||
|
rawLimit: string | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
const requested = Number(rawLimit);
|
||||||
|
return requested > 0 ? Math.min(requested, formatRowCap(format)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content type + file extension per format, for the download response headers. */
|
||||||
|
export const EXPORT_MIME: Record<ExportFormat, { type: string; ext: string }> = {
|
||||||
|
xlsx: {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
ext: 'xlsx',
|
||||||
|
},
|
||||||
|
csv: { type: 'text/csv; charset=utf-8', ext: 'csv' },
|
||||||
|
pdf: { type: 'application/pdf', ext: 'pdf' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller's requested subset, whitelisted against what they're allowed to have.
|
||||||
|
* Missing, empty, or all-unknown `raw` falls back to every entry rather than
|
||||||
|
* shipping a blank sheet. Generic over `{ key }` so it serves both a report's
|
||||||
|
* `columns` and a dataset's `fields`.
|
||||||
|
*/
|
||||||
|
export function pickByKey<T extends { key: string }>(all: T[], raw: string | undefined): T[] {
|
||||||
|
const requested = raw?.split(',').filter(Boolean);
|
||||||
|
const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all;
|
||||||
|
return filtered.length ? filtered : all;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { coerceFilterParams, RawFilterQuery } from './export-filter.util';
|
||||||
|
import {
|
||||||
|
buildExportCountQuery,
|
||||||
|
buildExportQuery,
|
||||||
|
resolveExportSort,
|
||||||
|
} from './export-query.builder';
|
||||||
|
import { ExportDataset, ExportField } from './export.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportRunnerService {
|
||||||
|
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||||
|
|
||||||
|
private context(dataset: ExportDataset, raw: RawFilterQuery, directions: string[] | null) {
|
||||||
|
return { ds: this.ds, params: coerceFilterParams(dataset.filters, raw), directions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact row count for the current filters. Exact rather than estimated
|
||||||
|
* because lazy joins are all left joins to to-one sides, so the count cannot
|
||||||
|
* depend on which fields the caller picked.
|
||||||
|
*/
|
||||||
|
async count(
|
||||||
|
dataset: ExportDataset,
|
||||||
|
raw: RawFilterQuery,
|
||||||
|
directions: string[] | null,
|
||||||
|
): Promise<number> {
|
||||||
|
const qb = buildExportCountQuery(dataset, this.context(dataset, raw, directions));
|
||||||
|
const row = await qb.getRawOne<{ total: number }>();
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matching rows.
|
||||||
|
*
|
||||||
|
* `limit` is the caller's deliberate "first N" truncation — honoured
|
||||||
|
* silently, because they asked for it. `cap` is the format's hard ceiling —
|
||||||
|
* exceeding it throws, because a silently short file is worse than a clear
|
||||||
|
* error: nothing downstream reveals that rows are missing.
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
dataset: ExportDataset,
|
||||||
|
fields: ExportField[],
|
||||||
|
raw: RawFilterQuery,
|
||||||
|
directions: string[] | null,
|
||||||
|
{ cap, limit }: { cap: number; limit?: number },
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const ctx = this.context(dataset, raw, directions);
|
||||||
|
const qb = buildExportQuery(dataset, fields, ctx);
|
||||||
|
|
||||||
|
const sort = resolveExportSort(dataset, fields, raw.sortBy, raw.sortOrder);
|
||||||
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
|
|
||||||
|
const ceiling = limit ?? cap;
|
||||||
|
// ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
|
||||||
|
// exactly that many rows" from "there are more".
|
||||||
|
const items = await qb.limit(ceiling + 1).getRawMany();
|
||||||
|
if (items.length <= ceiling) return items;
|
||||||
|
|
||||||
|
// Asked to be truncated -> truncate. Hit the hard cap -> say so.
|
||||||
|
if (limit !== undefined) return items.slice(0, limit);
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This export has more than ${cap.toLocaleString()} rows, the limit for this format. Narrow the filters, or export a smaller number of rows.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal file
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { bookingsDataset } from './datasets/bookings.dataset';
|
||||||
|
import { contractsDataset } from './datasets/contracts.dataset';
|
||||||
|
import { customersDataset } from './datasets/customers.dataset';
|
||||||
|
import { invoicesDataset } from './datasets/invoices.dataset';
|
||||||
|
import { locomotivesDataset } from './datasets/locomotives.dataset';
|
||||||
|
import { paymentsDataset } from './datasets/payments.dataset';
|
||||||
|
import { trainSchedulesDataset } from './datasets/train-schedules.dataset';
|
||||||
|
import { trainsDataset } from './datasets/trains.dataset';
|
||||||
|
import { wagonsDataset } from './datasets/wagons.dataset';
|
||||||
|
import { ExportDataset } from './export.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every exportable dataset.
|
||||||
|
*
|
||||||
|
* Adding one = a new file under `datasets/` + an entry here. No frontend edit,
|
||||||
|
* no route, no permission seed — the dialog is driven entirely by the catalog
|
||||||
|
* this registry serves, and a dataset reuses its module's existing `view` key.
|
||||||
|
*/
|
||||||
|
export const DATASETS: ExportDataset[] = [
|
||||||
|
bookingsDataset,
|
||||||
|
contractsDataset,
|
||||||
|
customersDataset,
|
||||||
|
invoicesDataset,
|
||||||
|
paymentsDataset,
|
||||||
|
trainSchedulesDataset,
|
||||||
|
locomotivesDataset,
|
||||||
|
trainsDataset,
|
||||||
|
wagonsDataset,
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_KEY = new Map(DATASETS.map((d) => [d.key, d]));
|
||||||
|
|
||||||
|
export const getDataset = (key: string): ExportDataset | undefined => BY_KEY.get(key);
|
||||||
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal file
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
DataSource,
|
||||||
|
EntityTarget,
|
||||||
|
ObjectLiteral,
|
||||||
|
ObjectType,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { ExportFilterDef } from './export-filter.util';
|
||||||
|
import { ExportFieldType } from './tabular-export.service';
|
||||||
|
|
||||||
|
export type { ExportFieldType };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lazily-applied relation.
|
||||||
|
*
|
||||||
|
* There is deliberately no `kind: 'inner' | 'left'` here — every join is
|
||||||
|
* emitted as a LEFT JOIN, and the type makes anything else unrepresentable.
|
||||||
|
* An inner join added only because someone ticked a checkbox would silently
|
||||||
|
* change the rowset (ticking "Customer TIN" would drop every booking with a
|
||||||
|
* null company_id), so two exports of the same filters would disagree on their
|
||||||
|
* row count. Anything that genuinely must narrow rows belongs in `scope()`,
|
||||||
|
* where it is unconditional and visible.
|
||||||
|
*
|
||||||
|
* The payoff: because a left join to a to-one side can neither add nor remove
|
||||||
|
* rows, the row count is independent of which fields are selected — which is
|
||||||
|
* what lets the count endpoint be exact rather than an estimate.
|
||||||
|
*/
|
||||||
|
export interface ExportJoin {
|
||||||
|
/** Alias used by field `select` expressions and by `requires`. */
|
||||||
|
alias: string;
|
||||||
|
/** Entity class. Narrower than `EntityTarget` to match TypeORM's join overload. */
|
||||||
|
entity: ObjectType<ObjectLiteral>;
|
||||||
|
/** ON condition; may reference the base alias and any alias in `requires`. */
|
||||||
|
on: string;
|
||||||
|
/** Other join aliases this join's ON clause depends on. Resolved transitively. */
|
||||||
|
requires?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One exportable column.
|
||||||
|
*
|
||||||
|
* `select` must yield exactly ONE value per base row. To surface a one-to-many
|
||||||
|
* relation (a company's profiles, a booking's containers), aggregate inside a
|
||||||
|
* correlated subquery — `(SELECT string_agg(...) FROM ... WHERE ... = base.id)`
|
||||||
|
* — rather than adding a join, which would multiply rows and break the count.
|
||||||
|
*
|
||||||
|
* Sensitive columns are simply never declared: raw gateway payloads
|
||||||
|
* (payments.raw_initiation, client_action), signature/crypto blobs
|
||||||
|
* (invoices.eims_signed_qr), internal error dumps (eims_last_error), raw jsonb
|
||||||
|
* snapshots (pricing_breakdown, document_snapshot, financial_terms,
|
||||||
|
* attributes, business_license_files), bare internal user UUIDs, and internal
|
||||||
|
* review/rejection notes. Fields are opt-in, so omission is the whole
|
||||||
|
* enforcement mechanism.
|
||||||
|
*/
|
||||||
|
export interface ExportField {
|
||||||
|
/** Response key, sheet header id, and the picker's checkbox id. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ExportFieldType;
|
||||||
|
/** Scalar SQL projected as `key`. */
|
||||||
|
select: string;
|
||||||
|
/** Join aliases `select` references. Omit for base-table-only fields. */
|
||||||
|
requires?: string[];
|
||||||
|
/** Picker group id; must exist in the dataset's `groups`. */
|
||||||
|
group: string;
|
||||||
|
/** Pre-ticked when the dialog opens with no preset. */
|
||||||
|
default?: boolean;
|
||||||
|
/** ORDER BY expression. Presence makes the field sortable. */
|
||||||
|
sortExpr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportContext {
|
||||||
|
ds: DataSource;
|
||||||
|
/** Filter values, already coerced by `coerceFilterParams`. */
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
/** Trade-scope directions. `null` = unrestricted, `[]` = show nothing. */
|
||||||
|
directions: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDataset {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
group: 'Commercial' | 'Operations' | 'Finance' | 'Fleet';
|
||||||
|
/**
|
||||||
|
* Permission to export this dataset. Reuses the module's existing `view`
|
||||||
|
* key — if you may see these rows on their list page, you may export them.
|
||||||
|
* The export never returns a row the list endpoint would not.
|
||||||
|
*/
|
||||||
|
permission: string;
|
||||||
|
base: { entity: EntityTarget<ObjectLiteral>; alias: string };
|
||||||
|
joins: ExportJoin[];
|
||||||
|
/**
|
||||||
|
* Aliases applied unconditionally because `scope()` references them. This is
|
||||||
|
* the only reason a join is eager, and the count query applies exactly these.
|
||||||
|
*/
|
||||||
|
alwaysJoin?: string[];
|
||||||
|
groups: ExportGroup[];
|
||||||
|
fields: ExportField[];
|
||||||
|
filters: ExportFilterDef[];
|
||||||
|
/** Must name a field whose `sortExpr` references only the base alias. */
|
||||||
|
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||||
|
/**
|
||||||
|
* Base WHERE (soft-delete guard), filter application, and the trade-direction
|
||||||
|
* ACL. Runs identically for the count and download queries, so the row count
|
||||||
|
* the dialog shows is exactly what lands in the file.
|
||||||
|
*
|
||||||
|
* A dataset whose table carries a trade direction MUST apply it here, or the
|
||||||
|
* export leaks rows the user cannot see on the list page.
|
||||||
|
*/
|
||||||
|
scope(ctx: ExportContext, qb: SelectQueryBuilder<ObjectLiteral>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `GET /exports` serves. `select` / `requires` / `sortExpr` are raw SQL
|
||||||
|
* and a map of the schema — they never leave the server.
|
||||||
|
*/
|
||||||
|
export interface ExportCatalogEntry {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
group: ExportDataset['group'];
|
||||||
|
groups: ExportGroup[];
|
||||||
|
fields: Pick<ExportField, 'key' | 'label' | 'type' | 'group' | 'default'>[];
|
||||||
|
filters: ExportFilterDef[];
|
||||||
|
formats: ('csv' | 'xlsx' | 'pdf')[];
|
||||||
|
caps: { csv: number; xlsx: number; pdf: number };
|
||||||
|
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||||
|
}
|
||||||
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal file
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
|
import { resolveFilterOptions } from './export-filter.util';
|
||||||
|
import {
|
||||||
|
EXPORT_MIME,
|
||||||
|
formatRowCap,
|
||||||
|
pickByKey,
|
||||||
|
resolveExportFormat,
|
||||||
|
resolveRowLimit,
|
||||||
|
} from './export-request.util';
|
||||||
|
import { ExportRunnerService } from './export-runner.service';
|
||||||
|
import { DATASETS, getDataset } from './export.registry';
|
||||||
|
import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types';
|
||||||
|
import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service';
|
||||||
|
|
||||||
|
/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */
|
||||||
|
type RawExportQuery = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
|
||||||
|
* the schema — they never leave the server.
|
||||||
|
*/
|
||||||
|
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||||
|
key: dataset.key,
|
||||||
|
title: dataset.title,
|
||||||
|
description: dataset.description,
|
||||||
|
group: dataset.group,
|
||||||
|
groups: dataset.groups,
|
||||||
|
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
group,
|
||||||
|
default: isDefault,
|
||||||
|
})),
|
||||||
|
filters: dataset.filters,
|
||||||
|
formats: ['csv', 'xlsx', 'pdf'],
|
||||||
|
caps: CAPS,
|
||||||
|
defaultSort: dataset.defaultSort,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic table export. One dataset per major table, each describing far more
|
||||||
|
* fields than its list page shows — including related-entity detail.
|
||||||
|
*/
|
||||||
|
@ApiTags('Exports')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('exports')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
export class ExportsController {
|
||||||
|
constructor(
|
||||||
|
private readonly runner: ExportRunnerService,
|
||||||
|
private readonly writer: TabularExportService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List datasets the caller has permission to export' })
|
||||||
|
async catalog(@CurrentUser() user: TCurrentUser): Promise<ExportCatalogEntry[]> {
|
||||||
|
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
|
||||||
|
return Promise.all(
|
||||||
|
allowed.map(async (d) => ({
|
||||||
|
...toCatalogEntry(d),
|
||||||
|
filters: await resolveFilterOptions(d.filters, this.dataSource),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key/count')
|
||||||
|
@ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' })
|
||||||
|
async count(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Query() query: RawExportQuery,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<{ total: number; caps: typeof CAPS }> {
|
||||||
|
const dataset = this.resolve(key, user);
|
||||||
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
const total = await this.runner.count(dataset, query, directions);
|
||||||
|
return { total, caps: CAPS };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key/download')
|
||||||
|
@ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' })
|
||||||
|
async download(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const dataset = this.resolve(key, user);
|
||||||
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
const format = resolveExportFormat(query.format);
|
||||||
|
const fields = this.resolveFields(dataset, query.fields);
|
||||||
|
|
||||||
|
const rows = await this.runner.run(dataset, fields, query, directions, {
|
||||||
|
cap: formatRowCap(format),
|
||||||
|
limit: resolveRowLimit(format, query.limit),
|
||||||
|
});
|
||||||
|
const doc = {
|
||||||
|
title: dataset.title,
|
||||||
|
description: dataset.description,
|
||||||
|
label: `export:${dataset.key}`,
|
||||||
|
columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })),
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
const buffer =
|
||||||
|
format === 'pdf'
|
||||||
|
? await this.writer.toPdf(doc)
|
||||||
|
: format === 'csv'
|
||||||
|
? await this.writer.toCsv(doc)
|
||||||
|
: await this.writer.toXlsx(doc);
|
||||||
|
|
||||||
|
const mime = EXPORT_MIME[format];
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`);
|
||||||
|
res.setHeader('Content-Type', mime.type);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requested fields, whitelisted against the dataset. No `fields=` means the
|
||||||
|
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
|
||||||
|
* all of them on an unparameterised call is nobody's intent.
|
||||||
|
*/
|
||||||
|
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
|
||||||
|
if (raw?.trim()) {
|
||||||
|
const picked = pickByKey(dataset.fields, raw);
|
||||||
|
// pickByKey falls back to everything when nothing matched; for a dataset
|
||||||
|
// the safer read of "all keys unknown" is still the default set.
|
||||||
|
if (picked.length !== dataset.fields.length) return picked;
|
||||||
|
}
|
||||||
|
const defaults = dataset.fields.filter((f) => f.default);
|
||||||
|
return defaults.length ? defaults : dataset.fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolve(key: string, user: TCurrentUser): ExportDataset {
|
||||||
|
const dataset = getDataset(key);
|
||||||
|
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
|
||||||
|
// Export rides the dataset's own list-page view permission: if you may see
|
||||||
|
// these rows, you may export them.
|
||||||
|
assertFreightPermission(user, dataset.permission);
|
||||||
|
return dataset;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||||
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
|
import { ExportRunnerService } from './export-runner.service';
|
||||||
|
import { ExportsController } from './exports.controller';
|
||||||
|
import { TabularExportService } from './tabular-export.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic table export: a dataset registry describing far more fields than each
|
||||||
|
* list page shows (related-entity detail included), plus the shared tabular
|
||||||
|
* writer (csv / xlsx / pdf) the reports module also writes through.
|
||||||
|
*
|
||||||
|
* `TabularExportService` is exported so ReportsModule can reuse it without
|
||||||
|
* pulling in the dataset machinery.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [DocumentsModule, UserTradeAccessModule],
|
||||||
|
controllers: [ExportsController],
|
||||||
|
providers: [TabularExportService, ExportRunnerService],
|
||||||
|
exports: [TabularExportService],
|
||||||
|
})
|
||||||
|
export class ExportsModule {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { TabularDoc, TabularExportService } from './tabular-export.service';
|
||||||
|
|
||||||
|
/** The PDF path is puppeteer-backed; these specs only cover the sheet writers. */
|
||||||
|
const service = new TabularExportService(null as unknown as PdfRenderService);
|
||||||
|
|
||||||
|
const doc: TabularDoc = {
|
||||||
|
title: 'Bookings',
|
||||||
|
description: 'every booking',
|
||||||
|
label: 'test',
|
||||||
|
columns: [
|
||||||
|
{ key: 'ref', label: 'Reference', type: 'string' },
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string' },
|
||||||
|
{ key: 'amount', label: 'Amount', type: 'money' },
|
||||||
|
{ key: 'gov', label: 'Government', type: 'boolean' },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ ref: 'BK-1', customer: 'Acme, Inc.', amount: 1234.5, gov: true },
|
||||||
|
{ ref: 'BK-2', customer: 'Quote "Q" Ltd', amount: null, gov: false },
|
||||||
|
],
|
||||||
|
kpis: [{ label: 'Bookings', value: 2 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TabularExportService.toCsv', () => {
|
||||||
|
it('quotes a value containing the delimiter — the reason we do not hand-roll join(",")', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv).toContain('"Acme, Inc."');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes embedded double quotes by doubling them', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv).toContain('"Quote ""Q"" Ltd"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts at the header row — no KPI preamble, so the file parses as a plain table', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv.split('\n')[0]).toBe('Reference,Customer,Amount,Government');
|
||||||
|
expect(csv).not.toContain('Bookings: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits one line per row plus the header', async () => {
|
||||||
|
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||||
|
expect(csv.trim().split('\n').filter(Boolean)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only the selected columns are written, in the order given', async () => {
|
||||||
|
const csv = (
|
||||||
|
await service.toCsv({ ...doc, columns: [doc.columns[2], doc.columns[0]] })
|
||||||
|
).toString('utf8');
|
||||||
|
expect(csv.split('\n')[0]).toBe('Amount,Reference');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TabularExportService.toXlsx', () => {
|
||||||
|
it('writes a real xlsx (a zip, so it starts with the PK magic bytes)', async () => {
|
||||||
|
const buffer = await service.toXlsx(doc);
|
||||||
|
expect(buffer.subarray(0, 2).toString('utf8')).toBe('PK');
|
||||||
|
expect(buffer.length).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a title longer than Excel\'s 31-char sheet-name limit does not throw', async () => {
|
||||||
|
const longTitle = 'A'.repeat(60);
|
||||||
|
await expect(service.toXlsx({ ...doc, title: longTitle })).resolves.toBeInstanceOf(Buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||||
|
|
||||||
|
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
|
||||||
|
// WorkbookWriter if an export ever needs to outgrow XLSX_ROW_CAP.
|
||||||
|
export const XLSX_ROW_CAP = 50_000;
|
||||||
|
// ponytail: CSV is buffered through the same Workbook as xlsx, so it shares the
|
||||||
|
// cap. Switch to qb.stream() + res.write() if a dataset needs more than this.
|
||||||
|
export const CSV_ROW_CAP = 50_000;
|
||||||
|
// ponytail: HTML→PDF render cost grows with row count; larger exports must
|
||||||
|
// use XLSX or CSV instead.
|
||||||
|
export const PDF_ROW_CAP = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value types a tabular export understands. A superset of `ReportColumn['type']`
|
||||||
|
* so a report's own columns are assignable here unchanged.
|
||||||
|
*/
|
||||||
|
export type ExportFieldType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'money'
|
||||||
|
| 'tons'
|
||||||
|
| 'percent'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
|
| 'boolean';
|
||||||
|
|
||||||
|
/** The minimum a column must describe to be written to a sheet. */
|
||||||
|
export interface ExportColumnLike {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ExportFieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Headline figures printed above the table. xlsx/pdf only — never in CSV. */
|
||||||
|
export interface ExportKpiLike {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tabular document, independent of where the rows came from. A report and a
|
||||||
|
* dataset export both reduce to this, which is what lets them share one writer.
|
||||||
|
*/
|
||||||
|
export interface TabularDoc {
|
||||||
|
/** Sheet name (truncated to Excel's 31-char limit) and the PDF's <h1>. */
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
|
||||||
|
label: string;
|
||||||
|
columns: ExportColumnLike[];
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
kpis?: ExportKpiLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
|
||||||
|
money: '#,##0.00',
|
||||||
|
tons: '#,##0.0',
|
||||||
|
percent: '0"%"',
|
||||||
|
number: '#,##0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCell(value: unknown, type: ExportFieldType): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (type === 'money' || type === 'number') {
|
||||||
|
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
|
||||||
|
if (type === 'percent') return `${value}%`;
|
||||||
|
if (type === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TabularExportService {
|
||||||
|
constructor(private readonly pdfRender: PdfRenderService) {}
|
||||||
|
|
||||||
|
async toXlsx(doc: TabularDoc): Promise<Buffer> {
|
||||||
|
const workbook = this.buildWorkbook(doc, { includeKpis: true });
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV via ExcelJS's own writer, off the same Workbook xlsx builds — it already
|
||||||
|
* handles quoting, embedded commas and embedded newlines. Hand-rolling
|
||||||
|
* `row.join(',')` breaks on the first customer name containing a comma.
|
||||||
|
*
|
||||||
|
* KPIs are deliberately omitted: a preamble row plus a blank row before the
|
||||||
|
* header stops the file parsing as a plain table, and CSV's whole point here
|
||||||
|
* is being machine-readable.
|
||||||
|
*/
|
||||||
|
async toCsv(doc: TabularDoc): Promise<Buffer> {
|
||||||
|
const workbook = this.buildWorkbook(doc, { includeKpis: false });
|
||||||
|
const buffer = await workbook.csv.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toPdf(doc: TabularDoc): Promise<Buffer> {
|
||||||
|
const html = this.buildHtml(doc);
|
||||||
|
return this.pdfRender.htmlToPdfBuffer(html, {
|
||||||
|
label: doc.label,
|
||||||
|
landscape: true,
|
||||||
|
// Without this, a box with no Chromium silently degrades to
|
||||||
|
// genericFallbackPdf — a ~900-character text dump instead of a table.
|
||||||
|
// buildTabularFallbackPdf parses exactly the markup buildHtml emits.
|
||||||
|
fallback: buildTabularFallbackPdf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWorkbook(doc: TabularDoc, opts: { includeKpis: boolean }): ExcelJS.Workbook {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet(doc.title.slice(0, 31));
|
||||||
|
const { columns, rows, kpis } = doc;
|
||||||
|
|
||||||
|
if (opts.includeKpis && kpis?.length) {
|
||||||
|
sheet.addRow(
|
||||||
|
kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`),
|
||||||
|
);
|
||||||
|
sheet.addRow([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = sheet.addRow(columns.map((c) => c.label));
|
||||||
|
headerRow.font = { bold: true };
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
sheet.addRow(columns.map((c) => row[c.key] ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.forEach((col, i) => {
|
||||||
|
const format = NUMBER_FORMAT[col.type];
|
||||||
|
const excelCol = sheet.getColumn(i + 1);
|
||||||
|
excelCol.width = Math.max(col.label.length + 2, 12);
|
||||||
|
if (format) excelCol.numFmt = format;
|
||||||
|
});
|
||||||
|
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHtml(doc: TabularDoc): string {
|
||||||
|
const { title, description, columns, rows, kpis } = doc;
|
||||||
|
const esc = (v: unknown) =>
|
||||||
|
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const kpiHtml = kpis?.length
|
||||||
|
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
|
||||||
|
.map(
|
||||||
|
(k) =>
|
||||||
|
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
|
||||||
|
)
|
||||||
|
.join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||||
|
const body = rows
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
|
||||||
|
h1 { font-size: 16px; margin-bottom: 4px; }
|
||||||
|
p.subtitle { color: #666; margin: 0 0 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||||
|
th { background: #f3f3f3; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1>${esc(title)}</h1>
|
||||||
|
<p class="subtitle">${esc(description ?? '')}</p>
|
||||||
|
${kpiHtml}
|
||||||
|
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every version of every document on a resource, oldest first — superseded
|
||||||
|
* versions included. One query for a whole document grid's upload history.
|
||||||
|
*/
|
||||||
|
findAllVersionsByResource(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { resourceId, resource },
|
||||||
|
withDeleted: true,
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Documents belonging to any of the given resources that a reviewer has asked
|
* Documents belonging to any of the given resources that a reviewer has asked
|
||||||
* the customer to correct. Used by the approval gate, so it takes a list of
|
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||||
|
|||||||
@@ -327,6 +327,14 @@ export class FilesService {
|
|||||||
return this.filesRepository.findByResource(resourceId, resource);
|
return this.filesRepository.findByResource(resourceId, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All versions of every document on a resource (superseded included), oldest first. */
|
||||||
|
findAllVersionsByResource(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return this.filesRepository.findAllVersionsByResource(resourceId, resource);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files for many resources of one kind, grouped by resource id. Resources with
|
* Files for many resources of one kind, grouped by resource id. Resources with
|
||||||
* no files are absent from the map (callers should default to `[]`).
|
* no files are absent from the map (callers should default to `[]`).
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TARGET_DIMENSIONS,
|
||||||
|
TARGET_METRICS,
|
||||||
|
TARGET_PERIOD_TYPES,
|
||||||
|
TargetDimension,
|
||||||
|
TargetMetric,
|
||||||
|
TargetPeriodType,
|
||||||
|
} from '../entities/operations-target.entity';
|
||||||
|
|
||||||
|
const toNumber = ({ value }: { value: unknown }) =>
|
||||||
|
value === '' || value == null ? value : Number(value);
|
||||||
|
|
||||||
|
export class CreateOperationsTargetDto {
|
||||||
|
@ApiProperty({ enum: TARGET_PERIOD_TYPES })
|
||||||
|
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
|
||||||
|
periodType!: TargetPeriodType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '2026-08-01',
|
||||||
|
description: 'Any date inside the bucket — normalised to the bucket start on write.',
|
||||||
|
})
|
||||||
|
@IsDateString()
|
||||||
|
periodStart!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TARGET_METRICS })
|
||||||
|
@IsIn(TARGET_METRICS as unknown as string[])
|
||||||
|
metric!: TargetMetric;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TARGET_DIMENSIONS })
|
||||||
|
@IsIn(TARGET_DIMENSIONS as unknown as string[])
|
||||||
|
dimension!: TargetDimension;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||||
|
description: 'Category key, container-class key or yard code — not a display label.',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
dimensionKey!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1200 })
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
plannedValue!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
|
||||||
|
example: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
cargoCategory?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
import {
|
||||||
|
TARGET_DIMENSIONS,
|
||||||
|
TARGET_METRICS,
|
||||||
|
TARGET_PERIOD_TYPES,
|
||||||
|
TargetDimension,
|
||||||
|
TargetMetric,
|
||||||
|
TargetPeriodType,
|
||||||
|
} from '../entities/operations-target.entity';
|
||||||
|
|
||||||
|
export class ListOperationsTargetsQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
|
||||||
|
periodType?: TargetPeriodType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: TARGET_METRICS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TARGET_METRICS as unknown as string[])
|
||||||
|
metric?: TargetMetric;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: TARGET_DIMENSIONS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TARGET_DIMENSIONS as unknown as string[])
|
||||||
|
dimension?: TargetDimension;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
const toNumber = ({ value }: { value: unknown }) =>
|
||||||
|
value === '' || value == null ? value : Number(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every field optional — the backoffice form PATCHes only what changed. A
|
||||||
|
* standard of zero is rejected: it would make every implement-rate division
|
||||||
|
* blow up or read as infinite achievement.
|
||||||
|
*/
|
||||||
|
export class UpdateOperationsStandardsDto {
|
||||||
|
@ApiPropertyOptional({ example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
stationStandardHoursEthiopia?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 13 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
stationStandardHoursDjibouti?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 65 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
cycleStandardHoursContainer?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 88 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
cycleStandardHoursBulkDmp?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 96 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
cycleStandardHoursBulkNagad?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 96 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
cycleStandardHoursBulkBcc?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 21 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
defaultLegStandardHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 30 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
delayToleranceMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsFull20ft?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 40 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsFull40ft?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 2.24 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsEmpty20ft?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3.88 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsEmpty40ft?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 70 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsPerWagonGeneral?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 38 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
chargedTonsPerWagonPerishable?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
defaultFullTrainsetWagons?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
|
||||||
|
import { CreateOperationsTargetDto } from './create-operations-target.dto';
|
||||||
|
|
||||||
|
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* numeric comes back from pg as a string. Every value here is arithmetic in a
|
||||||
|
* report expression, so convert on read rather than making each caller do it.
|
||||||
|
*/
|
||||||
|
const asNumber = {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table holding the railway's operating standards — the numbers the
|
||||||
|
* operations reports measure actual performance against. Same single-row shape
|
||||||
|
* as `logo_settings` and `exchange_settings`; the app never inserts a second row.
|
||||||
|
*
|
||||||
|
* These live in the database rather than in a constants file because the
|
||||||
|
* business treats them as tunable (the corridor standard is explicitly
|
||||||
|
* described as "flexible"), and a planner must be able to change one without a
|
||||||
|
* deployment.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'operations_standards' })
|
||||||
|
export class OperationsStandard extends BaseEntity {
|
||||||
|
/** Standard time a train may stand at an Ethiopian station, in hours. */
|
||||||
|
@Column({
|
||||||
|
name: 'station_standard_hours_ethiopia',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 10,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
stationStandardHoursEthiopia!: number;
|
||||||
|
|
||||||
|
/** Standard time a train may stand at a Djibouti station, in hours. */
|
||||||
|
@Column({
|
||||||
|
name: 'station_standard_hours_djibouti',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 13,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
stationStandardHoursDjibouti!: number;
|
||||||
|
|
||||||
|
/** Container turn-around cycle: 10 + 21 + 13 + 21. */
|
||||||
|
@Column({
|
||||||
|
name: 'cycle_standard_hours_container',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 65,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
cycleStandardHoursContainer!: number;
|
||||||
|
|
||||||
|
/** Bulk cycle via DMP: 13 + 21 + 33 + 21. */
|
||||||
|
@Column({
|
||||||
|
name: 'cycle_standard_hours_bulk_dmp',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 88,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
cycleStandardHoursBulkDmp!: number;
|
||||||
|
|
||||||
|
/** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */
|
||||||
|
@Column({
|
||||||
|
name: 'cycle_standard_hours_bulk_nagad',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 96,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
cycleStandardHoursBulkNagad!: number;
|
||||||
|
|
||||||
|
/** Bulk cycle via BCC: 13 + 21 + 41 + 21. */
|
||||||
|
@Column({
|
||||||
|
name: 'cycle_standard_hours_bulk_bcc',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 96,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
cycleStandardHoursBulkBcc!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard running time for one corridor leg, used when the yard pair has no
|
||||||
|
* `yard_distances.standard_hours` of its own.
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'default_leg_standard_hours',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 6,
|
||||||
|
scale: 2,
|
||||||
|
default: 21,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
defaultLegStandardHours!: number;
|
||||||
|
|
||||||
|
/** Grace on top of the leg standard before a train counts as delayed. */
|
||||||
|
@Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 })
|
||||||
|
delayToleranceMinutes!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per laden 20ft container. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_full_20ft',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 20,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsFull20ft!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per laden 40ft container. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_full_40ft',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 40,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsFull40ft!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per empty 20ft container. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_empty_20ft',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 2.24,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsEmpty20ft!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per empty 40ft container. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_empty_40ft',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 3.88,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsEmpty40ft!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_per_wagon_general',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 70,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsPerWagonGeneral!: number;
|
||||||
|
|
||||||
|
/** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */
|
||||||
|
@Column({
|
||||||
|
name: 'charged_tons_per_wagon_perishable',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 2,
|
||||||
|
default: 38,
|
||||||
|
transformer: asNumber,
|
||||||
|
})
|
||||||
|
chargedTonsPerWagonPerishable!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagons in a full trainset when the cargo type has no
|
||||||
|
* `cargo_types.full_trainset_wagons` of its own.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
|
||||||
|
defaultFullTrainsetWagons!: number;
|
||||||
|
|
||||||
|
/** IAM user id of the last operator to change a standard. */
|
||||||
|
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||||
|
updatedById?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
|
||||||
|
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
|
||||||
|
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
|
||||||
|
|
||||||
|
/** What is being planned. */
|
||||||
|
export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const;
|
||||||
|
export type TargetMetric = (typeof TARGET_METRICS)[number];
|
||||||
|
|
||||||
|
/** Which axis `dimensionKey` names. */
|
||||||
|
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
|
||||||
|
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How each stored code reads on screen. The columns are enums the reports match
|
||||||
|
* on, so the stored values must stay exactly as they are — these exist for the
|
||||||
|
* admin grid, which otherwise shows `VOLUME_TONS` and `cargo_category` verbatim.
|
||||||
|
*/
|
||||||
|
export const TARGET_METRIC_LABELS: Record<TargetMetric, string> = {
|
||||||
|
TEU: 'TEU',
|
||||||
|
TRAINSET: 'Trainsets',
|
||||||
|
VOLUME_TONS: 'Volume (tons)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
|
||||||
|
cargo_category: 'Cargo category',
|
||||||
|
station: 'Station',
|
||||||
|
container_class: 'Container class',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
|
||||||
|
week: 'Weekly',
|
||||||
|
month: 'Monthly',
|
||||||
|
quarter: 'Quarterly',
|
||||||
|
year: 'Yearly',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The planned side of every "Plan / Operated / Implement Rate" table in the
|
||||||
|
* operations reporting spec. One row is one planned number: a period, a metric,
|
||||||
|
* and the dimension value it applies to.
|
||||||
|
*
|
||||||
|
* `dimensionKey` holds a category key (not a label) — the same keys
|
||||||
|
* `operations-classification.ts` emits, so a report can join on it directly.
|
||||||
|
*
|
||||||
|
* Uniqueness on the five-column slot is a partial index in the database
|
||||||
|
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted
|
||||||
|
* target can be re-created — the same choice `yard_distances` makes.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'operations_targets' })
|
||||||
|
@Index(['metric', 'periodType', 'periodStart'])
|
||||||
|
export class OperationsTarget extends BaseEntity {
|
||||||
|
@Column({ name: 'period_type', type: 'varchar', length: 10 })
|
||||||
|
periodType!: TargetPeriodType;
|
||||||
|
|
||||||
|
/** First day of the bucket, normalised on write (Monday, 1st, quarter start). */
|
||||||
|
@Column({ name: 'period_start', type: 'date' })
|
||||||
|
periodStart!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'metric', type: 'varchar', length: 20 })
|
||||||
|
metric!: TargetMetric;
|
||||||
|
|
||||||
|
@Column({ name: 'dimension', type: 'varchar', length: 20 })
|
||||||
|
dimension!: TargetDimension;
|
||||||
|
|
||||||
|
/** Category key, container-class key, or yard code — never a display label. */
|
||||||
|
@Column({ name: 'dimension_key', type: 'varchar', length: 60 })
|
||||||
|
dimensionKey!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'planned_value',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 14,
|
||||||
|
scale: 3,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
plannedValue!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only for `station` targets, where the plan is per station AND per cargo
|
||||||
|
* type — the OCC report plans Nagad–Mojo container and Nagad–Mojo fertilizer
|
||||||
|
* separately. Null on `cargo_category` and `container_class` targets, whose
|
||||||
|
* `dimensionKey` already carries the category.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true })
|
||||||
|
cargoCategory?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { OperationsStandard } from './entities/operations-standard.entity';
|
||||||
|
import { OperationsTarget } from './entities/operations-target.entity';
|
||||||
|
import { OperationsStandardsController } from './operations-standards.controller';
|
||||||
|
import { OperationsStandardsService } from './operations-standards.service';
|
||||||
|
import { OperationsTargetsController } from './operations-targets.controller';
|
||||||
|
import { OperationsTargetsService } from './operations-targets.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference data behind the operations reports: the railway's operating
|
||||||
|
* standards (one settings row) and the planned targets the reports compare
|
||||||
|
* actuals against.
|
||||||
|
*
|
||||||
|
* Global because the reports module reads the standards row on every run and
|
||||||
|
* has no other reason to import this.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
|
||||||
|
controllers: [OperationsStandardsController, OperationsTargetsController],
|
||||||
|
providers: [OperationsStandardsService, OperationsTargetsService],
|
||||||
|
exports: [OperationsStandardsService, OperationsTargetsService],
|
||||||
|
})
|
||||||
|
export class OperationsReportingModule {}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
|
||||||
|
import { OperationsStandardsService } from './operations-standards.service';
|
||||||
|
|
||||||
|
@ApiTags('operations-standards')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('operations-standards')
|
||||||
|
export class OperationsStandardsController {
|
||||||
|
constructor(private readonly service: OperationsStandardsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin])
|
||||||
|
@ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' })
|
||||||
|
get() {
|
||||||
|
return this.service.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin])
|
||||||
|
@ApiOperation({ summary: 'Change one or more operating standards' })
|
||||||
|
update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
return this.service.update(dto, user?.id ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { IsNull, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
|
||||||
|
import { OperationsStandard } from './entities/operations-standard.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the single `operations_standards` row — the times and tonnage factors
|
||||||
|
* every operations report measures actual performance against.
|
||||||
|
*
|
||||||
|
* The migration seeds the row, but `get()` creates it on demand as well: a
|
||||||
|
* report that cannot read a standard would have to fall back to a hardcoded
|
||||||
|
* number, which is exactly what putting these in the database was meant to
|
||||||
|
* avoid.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OperationsStandardsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(OperationsStandard)
|
||||||
|
private readonly repository: Repository<OperationsStandard>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(): Promise<OperationsStandard> {
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { deletedAt: IsNull() },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Every column has a database default, so an empty insert is the seed row.
|
||||||
|
return this.repository.save(this.repository.create({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
dto: UpdateOperationsStandardsDto,
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<OperationsStandard> {
|
||||||
|
const current = await this.get();
|
||||||
|
await this.repository.update(current.id, { ...dto, updatedById: userId });
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RuleEngineCreate,
|
||||||
|
RuleEngineDelete,
|
||||||
|
RuleEngineUpdate,
|
||||||
|
RuleEngineView,
|
||||||
|
} from '../../common/rule-engine-guards';
|
||||||
|
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
||||||
|
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
||||||
|
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
|
||||||
|
import { OperationsTargetsService } from './operations-targets.service';
|
||||||
|
|
||||||
|
@ApiTags('operations-targets')
|
||||||
|
@Controller('operations-targets')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class OperationsTargetsController {
|
||||||
|
constructor(private readonly service: OperationsTargetsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RuleEngineView('operations-targets')
|
||||||
|
@ApiOperation({ summary: 'List planned operational targets' })
|
||||||
|
findAll(@Query() query: ListOperationsTargetsQueryDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RuleEngineView('operations-targets')
|
||||||
|
@ApiOperation({ summary: 'Get a target by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RuleEngineCreate('operations-targets')
|
||||||
|
@ApiOperation({ summary: 'Create a planned target' })
|
||||||
|
create(@Body() dto: CreateOperationsTargetDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RuleEngineUpdate('operations-targets')
|
||||||
|
@ApiOperation({ summary: 'Update a planned target' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RuleEngineDelete('operations-targets')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Soft-delete a planned target' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Brackets, IsNull, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||||
|
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
||||||
|
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
||||||
|
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
|
||||||
|
import {
|
||||||
|
OperationsTarget,
|
||||||
|
TARGET_DIMENSION_LABELS,
|
||||||
|
TARGET_METRIC_LABELS,
|
||||||
|
TARGET_PERIOD_LABELS,
|
||||||
|
TargetPeriodType,
|
||||||
|
} from './entities/operations-target.entity';
|
||||||
|
import {
|
||||||
|
CARGO_CATEGORIES,
|
||||||
|
CONTAINER_CLASSES,
|
||||||
|
} from '../reports/operations-classification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises any date inside a bucket to the bucket's first day, matching
|
||||||
|
* Postgres `date_trunc` — which is what the reports group by. Week starts
|
||||||
|
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
|
||||||
|
*
|
||||||
|
* Done in UTC throughout: the stored column is a bare `date`, and running the
|
||||||
|
* arithmetic in local time would shift a 1st-of-month target into the previous
|
||||||
|
* month for anyone east of Greenwich.
|
||||||
|
*/
|
||||||
|
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
|
||||||
|
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
|
||||||
|
switch (periodType) {
|
||||||
|
case 'week': {
|
||||||
|
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
|
||||||
|
const offset = (d.getUTCDay() + 6) % 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() - offset);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
d.setUTCDate(1);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
d.setUTCMonth(0, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat row shape for the backoffice config grid: the stored codes stay put —
|
||||||
|
* the reports join on them — and readable twins ride alongside, the same way
|
||||||
|
* `YardDistancesService` adds `fromYardLabel`.
|
||||||
|
*/
|
||||||
|
export type OperationsTargetRow = OperationsTarget & {
|
||||||
|
metricLabel: string;
|
||||||
|
dimensionLabel: string;
|
||||||
|
periodLabel: string;
|
||||||
|
appliesToLabel: string;
|
||||||
|
cargoCategoryLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved per dimension, not from one merged map: `CONTAINER_EXPORT` is in
|
||||||
|
* both vocabularies and reads differently in each ("Export container" as a
|
||||||
|
* cargo category, "Full export container" as a container class). Merging them
|
||||||
|
* silently gave every cargo-category row the container-class wording.
|
||||||
|
*/
|
||||||
|
const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
|
||||||
|
cargo_category: new Map(CARGO_CATEGORIES.map((o) => [o.value, o.label])),
|
||||||
|
container_class: new Map(CONTAINER_CLASSES.map((o) => [o.value, o.label])),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OperationsTargetsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(OperationsTarget)
|
||||||
|
private readonly repository: Repository<OperationsTarget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Yard code → label, for station targets. Reference data, read per list. */
|
||||||
|
private async yardLabels(): Promise<Map<string, string>> {
|
||||||
|
const rows = await this.repository.manager.query<Array<{ code: string; label: string }>>(
|
||||||
|
`SELECT code, label FROM freight.yards WHERE deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r) => [r.code, r.label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRow(target: OperationsTarget, yards: Map<string, string>): OperationsTargetRow {
|
||||||
|
const appliesToLabel =
|
||||||
|
target.dimension === 'station'
|
||||||
|
? (yards.get(target.dimensionKey) ?? target.dimensionKey)
|
||||||
|
: (LABELS_BY_DIMENSION[target.dimension]?.get(target.dimensionKey) ??
|
||||||
|
target.dimensionKey);
|
||||||
|
|
||||||
|
return Object.assign(target, {
|
||||||
|
metricLabel: TARGET_METRIC_LABELS[target.metric] ?? target.metric,
|
||||||
|
dimensionLabel: TARGET_DIMENSION_LABELS[target.dimension] ?? target.dimension,
|
||||||
|
periodLabel: TARGET_PERIOD_LABELS[target.periodType] ?? target.periodType,
|
||||||
|
appliesToLabel,
|
||||||
|
// Only station targets carry one, and it is always a cargo category.
|
||||||
|
cargoCategoryLabel: target.cargoCategory
|
||||||
|
? (CARGO_CATEGORY_LABELS.get(target.cargoCategory) ?? target.cargoCategory)
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
query: ListOperationsTargetsQueryDto,
|
||||||
|
): Promise<PaginatedResponse<OperationsTargetRow>> {
|
||||||
|
const sortable: Record<string, string> = {
|
||||||
|
periodStart: 'target.period_start',
|
||||||
|
metric: 'target.metric',
|
||||||
|
dimension: 'target.dimension',
|
||||||
|
dimensionKey: 'target.dimension_key',
|
||||||
|
plannedValue: 'target.planned_value',
|
||||||
|
createdAt: 'target.created_at',
|
||||||
|
};
|
||||||
|
const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('target')
|
||||||
|
.orderBy(sortBy, query.sortOrder ?? 'DESC')
|
||||||
|
.addOrderBy('target.dimension_key', 'ASC');
|
||||||
|
|
||||||
|
if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType });
|
||||||
|
if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric });
|
||||||
|
if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension });
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
new Brackets((w) =>
|
||||||
|
w
|
||||||
|
.where('target.dimension_key ILIKE :s', { s: `%${query.search}%` })
|
||||||
|
.orWhere('target.note ILIKE :s', { s: `%${query.search}%` }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [page, yards] = await Promise.all([paginateQuery(qb, query), this.yardLabels()]);
|
||||||
|
return { ...page, items: page.items.map((t) => this.toRow(t, yards)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<OperationsTarget> {
|
||||||
|
const found = await this.repository.findOne({ where: { id } });
|
||||||
|
if (!found) throw new NotFoundException(`Operations target ${id} not found`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
|
||||||
|
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
|
||||||
|
const cargoCategory = dto.cargoCategory ?? null;
|
||||||
|
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
|
||||||
|
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
|
||||||
|
const current = await this.findById(id);
|
||||||
|
const periodType = dto.periodType ?? current.periodType;
|
||||||
|
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
|
||||||
|
const next = {
|
||||||
|
periodType,
|
||||||
|
periodStart,
|
||||||
|
metric: dto.metric ?? current.metric,
|
||||||
|
dimension: dto.dimension ?? current.dimension,
|
||||||
|
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
|
||||||
|
cargoCategory:
|
||||||
|
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
|
||||||
|
};
|
||||||
|
await this.assertSlotFree(next, id);
|
||||||
|
|
||||||
|
await this.repository.update(id, {
|
||||||
|
...next,
|
||||||
|
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
|
||||||
|
...(dto.note !== undefined ? { note: dto.note } : {}),
|
||||||
|
});
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.repository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One planned number per (period, metric, dimension value). The database
|
||||||
|
* enforces this too — the check is here to turn a 23505 into a message that
|
||||||
|
* says which slot is taken.
|
||||||
|
*/
|
||||||
|
private async assertSlotFree(
|
||||||
|
slot: Pick<
|
||||||
|
OperationsTarget,
|
||||||
|
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
|
||||||
|
>,
|
||||||
|
ignoreId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
periodType: slot.periodType,
|
||||||
|
periodStart: slot.periodStart,
|
||||||
|
metric: slot.metric,
|
||||||
|
dimension: slot.dimension,
|
||||||
|
dimensionKey: slot.dimensionKey,
|
||||||
|
cargoCategory: slot.cargoCategory ?? IsNull(),
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing && existing.id !== ignoreId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user