mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
"freight-api"
|
||||
"freight-portal"
|
||||
"freight-backoffice"
|
||||
"gps-tracker"
|
||||
"passenger-api"
|
||||
"passenger-portal"
|
||||
"passenger-backoffice"
|
||||
@@ -71,6 +72,7 @@ jobs:
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-gps-tracker/" && SERVICES+=("gps-tracker")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
@@ -109,7 +111,7 @@ jobs:
|
||||
- name: Resolve project and build env file
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
freight-api|freight-portal|freight-backoffice)
|
||||
freight-api|freight-portal|freight-backoffice|gps-tracker)
|
||||
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ coverage/
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -43,3 +43,4 @@ EXPOSE 3001
|
||||
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
||||
EXPOSE 5023
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
@@ -56,7 +57,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -69,6 +69,7 @@ import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seed
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -93,6 +94,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
|
||||
@Module({
|
||||
@@ -188,6 +190,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -199,6 +202,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
EdrTruckFleetSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
@@ -231,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -261,6 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
await this.edrTruckFleetSeeder.run();
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
44
apps/edr-freight-api/src/common/dto/pagination-query.dto.ts
Normal file
44
apps/edr-freight-api/src/common/dto/pagination-query.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Base query DTO for every paginated list endpoint. Extend it and add the
|
||||
* module's own filter fields; sort-field whitelists stay in the subclass
|
||||
* because the allowed columns differ per resource.
|
||||
*
|
||||
* All list endpoints built on this return the shared `PaginatedResponse<T>`
|
||||
* envelope from `@edr/types` (`items` + `meta`), produced by
|
||||
* `common/utils/pagination.util.ts`.
|
||||
*/
|
||||
export class PaginationQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(String(value), 10) || 1)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Free-text search, applied server-side (resource-specific columns).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => String(value).toUpperCase())
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
85
apps/edr-freight-api/src/common/utils/pagination.util.ts
Normal file
85
apps/edr-freight-api/src/common/utils/pagination.util.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { PaginatedResponse, PaginationMeta } from '@edr/types';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
/** Raw page/pageSize as they arrive from a query DTO (both optional). */
|
||||
export interface PageRequest {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
defaultPageSize?: number;
|
||||
maxPageSize?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedPage {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
skip: number;
|
||||
take: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
||||
export function normalizePagination(
|
||||
request: PageRequest,
|
||||
options: PaginationOptions = {},
|
||||
): NormalizedPage {
|
||||
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
||||
const maxPageSize = options.maxPageSize ?? MAX_PAGE_SIZE;
|
||||
|
||||
const page = Math.max(1, Math.floor(request.page ?? 1) || 1);
|
||||
const requested = Math.floor(request.pageSize ?? defaultPageSize) || defaultPageSize;
|
||||
const pageSize = Math.min(Math.max(1, requested), maxPageSize);
|
||||
|
||||
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
||||
}
|
||||
|
||||
export function buildPaginationMeta(
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): PaginationMeta {
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply skip/take to a query builder, run it, and wrap the result in the
|
||||
* shared `PaginatedResponse` envelope. Ordering and filtering must already be
|
||||
* applied by the caller.
|
||||
*/
|
||||
export async function paginateQuery<T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
request: PageRequest,
|
||||
options?: PaginationOptions,
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
const { page, pageSize, skip, take } = normalizePagination(request, options);
|
||||
const [items, total] = await qb.skip(skip).take(take).getManyAndCount();
|
||||
return { items, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate an already-materialized array. Prefer `paginateQuery` (DB-level
|
||||
* LIMIT/OFFSET); use this only for lists that are inherently in-memory.
|
||||
*/
|
||||
export function paginateArray<T>(
|
||||
rows: readonly T[],
|
||||
request: PageRequest,
|
||||
options?: PaginationOptions,
|
||||
): PaginatedResponse<T> {
|
||||
const { page, pageSize, skip } = normalizePagination(request, options);
|
||||
return {
|
||||
items: rows.slice(skip, skip + pageSize),
|
||||
meta: buildPaginationMeta(rows.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import Handlebars from 'handlebars';
|
||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||
export interface RenderedClause {
|
||||
text: string;
|
||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||
number: string;
|
||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||
depth: number;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
@@ -16,10 +20,26 @@ export interface RenderedArticle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a template article body into clauses. Format: one clause per line;
|
||||
* lines prefixed with "- " become bullets nested under the preceding clause.
|
||||
* A body that reduces to a single clause without bullets renders as a plain
|
||||
* paragraph rather than a numbered list of one.
|
||||
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
|
||||
* The token's segment count sets the clause depth; its digits are ignored —
|
||||
* numbering is recomputed sequentially so stale numbers self-heal.
|
||||
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
|
||||
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
|
||||
* end the line — that is an empty clause still being typed in the editor.
|
||||
*/
|
||||
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||
|
||||
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||
const MAX_CLAUSE_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* Parse a template article body into clauses. Format: one clause per line.
|
||||
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||
* sub-clause at that depth — the typed digits are stripped and renumbered
|
||||
* sequentially, so editing order never leaves stale numbers in the document.
|
||||
* Lines prefixed with "- " become bullets nested under the preceding clause.
|
||||
* A body that reduces to a single un-numbered clause without bullets renders
|
||||
* as a plain paragraph rather than a numbered list of one.
|
||||
*/
|
||||
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
|
||||
const lines = (body ?? '')
|
||||
@@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const clauses: RenderedClause[] = [];
|
||||
// counters[i] = current number at depth i+1; truncated when a shallower
|
||||
// clause arrives so deeper numbering restarts at 1.
|
||||
const counters: number[] = [];
|
||||
let sawNumberToken = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('- ')) {
|
||||
const bullet = line.slice(2).trim();
|
||||
if (clauses.length === 0) {
|
||||
clauses.push({ text: bullet, bullets: [] });
|
||||
counters.splice(0, counters.length, 1);
|
||||
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
|
||||
} else {
|
||||
clauses[clauses.length - 1].bullets.push(bullet);
|
||||
}
|
||||
} else {
|
||||
clauses.push({ text: line, bullets: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = CLAUSE_NUMBER_RE.exec(line);
|
||||
const token = match ? (match[1] ?? match[2]) : null;
|
||||
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
|
||||
// A sub-clause can only sit directly under an existing parent — "1.1.1"
|
||||
// typed as the first line clamps to whatever level is actually open.
|
||||
depth = Math.min(depth, counters.length + 1);
|
||||
if (match) sawNumberToken = true;
|
||||
|
||||
counters.splice(depth);
|
||||
while (counters.length < depth) counters.push(0);
|
||||
counters[depth - 1] += 1;
|
||||
|
||||
clauses.push({
|
||||
text: match ? line.slice(match[0].length).trim() : line,
|
||||
number: counters.slice(0, depth).join('.'),
|
||||
depth,
|
||||
bullets: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
||||
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
|
||||
return { paragraph: clauses[0].text, clauses: [] };
|
||||
}
|
||||
return { clauses };
|
||||
|
||||
@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
|
||||
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
|
||||
expect(parsed.clauses).toEqual([]);
|
||||
});
|
||||
|
||||
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
||||
const parsed = parseArticleBody(
|
||||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||
);
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||
['1', 1, 'Scope'],
|
||||
['1.1', 2, 'Rail transport'],
|
||||
['1.1.1', 3, 'Wagon supply'],
|
||||
['2', 1, 'Payment'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||
['1', 1],
|
||||
['2', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves prose that merely starts with a number un-tokenized', () => {
|
||||
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
|
||||
expect(parsed.clauses.map((c) => c.text)).toEqual([
|
||||
'10 tons is the minimum load.',
|
||||
'Payment in advance.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
|
||||
const parsed = parseArticleBody('1. Only clause.');
|
||||
expect(parsed.paragraph).toBeUndefined();
|
||||
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateTemplateText', () => {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{{else}}
|
||||
<ol class="clauses">
|
||||
{{#each clauses}}
|
||||
<li>
|
||||
<li class="clause depth-{{depth}}">
|
||||
<span class="clause-no">{{number}}.</span>
|
||||
{{text}}
|
||||
{{#if bullets.length}}
|
||||
<ul class="clause-bullets">
|
||||
|
||||
@@ -282,28 +282,27 @@
|
||||
.article-name { color: #0e5b45; }
|
||||
.article-paragraph { margin: 4px 0 0; }
|
||||
ol.clauses {
|
||||
counter-reset: clause;
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
ol.clauses > li {
|
||||
counter-increment: clause;
|
||||
ol.clauses > li.clause {
|
||||
margin-bottom: 6px;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
text-align: justify;
|
||||
}
|
||||
ol.clauses > li::before {
|
||||
ol.clauses .clause-no {
|
||||
color: #0e5b45;
|
||||
content: counter(clause) ".";
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
font-weight: 700;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
/* Sub-clause indentation: each outline level steps in. */
|
||||
ol.clauses > li.depth-2 { padding-left: 20px; }
|
||||
ol.clauses > li.depth-3 { padding-left: 40px; }
|
||||
ol.clauses > li.depth-4 { padding-left: 60px; }
|
||||
ol.clauses > li.depth-5 { padding-left: 80px; }
|
||||
ol.clauses > li.depth-6 { padding-left: 100px; }
|
||||
ul.clause-bullets {
|
||||
margin: 5px 0 2px;
|
||||
padding-left: 16px;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* `company_profiles.status` defaulted to 'active', so any insert that omitted
|
||||
* the column produced an operational role that was approved without ever being
|
||||
* reviewed. Every live write path already passes 'pending' explicitly; this
|
||||
* closes the hole at the schema level.
|
||||
*
|
||||
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
|
||||
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
|
||||
* flags a role that skipped review — but it also matches rows approved before
|
||||
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
|
||||
* judgement call about real customers, not something to automate here.
|
||||
*/
|
||||
export class CompanyProfileDefaultPending2100000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CompanyProfileDefaultPending2100000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Auto-load onto a selected train: a warehouse_loadings row now records WHICH
|
||||
* train the item was loaded onto (train_schedule_id), and wagon_id becomes
|
||||
* nullable because a schedule-level load may not resolve to a single wagon.
|
||||
*/
|
||||
export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface {
|
||||
name = 'WarehouseLoadingTrainAssociation2100000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings
|
||||
ALTER COLUMN wagon_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule
|
||||
ON freight.warehouse_loadings(train_schedule_id)
|
||||
WHERE train_schedule_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id
|
||||
`);
|
||||
// wagon_id stays nullable on revert: restoring NOT NULL would fail on rows
|
||||
// recorded without a wagon and re-introduce the outage this fixes.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial-batch splits no longer promote a ONE_TIME contract to GENERAL.
|
||||
* Instead the reduced booking is flagged is_split, and the booking gate lets
|
||||
* the customer book exactly the remainder under the still-ONE_TIME contract.
|
||||
*/
|
||||
export class AddBookingIsSplit2110000000000 implements MigrationInterface {
|
||||
name = 'AddBookingIsSplit2110000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE
|
||||
`);
|
||||
// Quantities the booking carried before the split — the remainder ledger
|
||||
// for ONE_TIME contracts, which have no quantity cap to derive it from.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Repair migration. `SeparateVehicleAvailability1890000000000` is recorded in
|
||||
* public.migrations but the `availability` column is absent on some databases
|
||||
* (recorded-but-not-applied drift). Because the original is already recorded,
|
||||
* TypeORM will not re-run it, so `vehiclesService.findAll` (a query builder that
|
||||
* selects every entity column) 500s with `column "availability" does not exist`.
|
||||
*
|
||||
* This re-adds the column idempotently and backfills. Safe to run everywhere:
|
||||
* `IF NOT EXISTS` makes it a no-op where the column already exists.
|
||||
*/
|
||||
export class RepairVehicleAvailabilityColumn2110000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "RepairVehicleAvailabilityColumn2110000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: dropping a column other code now depends on would reintroduce the
|
||||
// drift. The original SeparateVehicleAvailability migration owns the column.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Proof of delivery for EDR last-mile: recipient name, a captured signature
|
||||
* (stored as a file), delivery photos (file ids), notes, and the capture time.
|
||||
* Recorded when the driver completes the delivery.
|
||||
*/
|
||||
export class AddLastMileProofOfDelivery2120000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLastMileProofOfDelivery2120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160),
|
||||
ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS pod_notes text,
|
||||
ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
DROP COLUMN IF EXISTS pod_recipient_name,
|
||||
DROP COLUMN IF EXISTS pod_signature_file_id,
|
||||
DROP COLUMN IF EXISTS pod_photo_file_ids,
|
||||
DROP COLUMN IF EXISTS pod_notes,
|
||||
DROP COLUMN IF EXISTS pod_captured_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a frozen wagon-allocation snapshot to each train schedule.
|
||||
*
|
||||
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
|
||||
* cancel), the same physical wagons get released and re-pinned onto later trains.
|
||||
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
|
||||
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
|
||||
*
|
||||
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
|
||||
* physical wagon + booking allocations) captured at the transition. Non-editable
|
||||
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
|
||||
* legacy rows and while editable — the read path falls back to the live joins.
|
||||
*/
|
||||
export class AddScheduleWagonAllocationSnapshot2120000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddScheduleWagonAllocationSnapshot2120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
|
||||
`);
|
||||
}
|
||||
}
|
||||
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '@edr/api-common';
|
||||
|
||||
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
|
||||
import { AiBookingResult } from './types/ai-booking-result.type';
|
||||
import { MockAiService } from './mock-ai.service';
|
||||
|
||||
// @Public() — TODO: swap for real guard when this leaves dev/testing.
|
||||
// Safe while public: extracts + validates text only, never creates or
|
||||
// dispatches anything.
|
||||
@Public()
|
||||
@ApiTags('AI Assistant (mock)')
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly mockAiService: MockAiService) {}
|
||||
|
||||
@Post('booking/extract')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Mock AI: extract structured booking fields from free-text request',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
'Extracted fields, validation result, and next-step recommendation',
|
||||
})
|
||||
extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult {
|
||||
return this.mockAiService.extractBooking(dto.text);
|
||||
}
|
||||
}
|
||||
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AiController } from './ai.controller';
|
||||
import { MockAiService } from './mock-ai.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
providers: [MockAiService],
|
||||
exports: [MockAiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AiBookingRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'Free-text customer booking request to extract fields from',
|
||||
example:
|
||||
'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.',
|
||||
minLength: 5,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'text must not be empty' })
|
||||
@MinLength(5, { message: 'text must be at least 5 characters' })
|
||||
text!: string;
|
||||
}
|
||||
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
AiBookingResult,
|
||||
AiContainerType,
|
||||
AiDirection,
|
||||
AiExtractedBooking,
|
||||
AiRecommendation,
|
||||
AiValidationResult,
|
||||
} from './types/ai-booking-result.type';
|
||||
|
||||
/**
|
||||
* Deterministic keyword/regex "AI" for the booking assistant workflow.
|
||||
* No external AI calls — this class is the single seam to swap for a real
|
||||
* provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep
|
||||
* the `extractBooking(text): AiBookingResult` contract and replace the body.
|
||||
*/
|
||||
|
||||
const KNOWN_LOCATIONS = [
|
||||
'Djibouti',
|
||||
'Indode',
|
||||
'Modjo',
|
||||
'Adama',
|
||||
'Dire Dawa',
|
||||
'Addis Ababa',
|
||||
] as const;
|
||||
|
||||
const INLAND_LOCATIONS = new Set<string>([
|
||||
'Indode',
|
||||
'Modjo',
|
||||
'Adama',
|
||||
'Dire Dawa',
|
||||
'Addis Ababa',
|
||||
]);
|
||||
|
||||
// Longest names first so "Dire Dawa" wins before a shorter partial could.
|
||||
const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map((name) => name.replace(/\s+/g, '\\s+'))
|
||||
.join('|');
|
||||
|
||||
// Checked in order; first hit wins, so specific cargo words beat the
|
||||
// generic "refrigerated" fallback.
|
||||
const CARGO_KEYWORDS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/\belectronics\b/i, 'electronics'],
|
||||
[/\bcoffee\b/i, 'coffee'],
|
||||
[/\bwheat\b/i, 'wheat'],
|
||||
[/\bfertilizers?\b/i, 'fertilizer'],
|
||||
[/\bchemicals?\b/i, 'chemical'],
|
||||
[/\bmachinery\b/i, 'machinery'],
|
||||
[/\bmedicines?\b/i, 'medicine'],
|
||||
[/\bsesame\b/i, 'sesame'],
|
||||
[/\b(?:vehicles?|cars?)\b/i, 'vehicles'],
|
||||
[/\brefrigerated\b/i, 'refrigerated cargo'],
|
||||
];
|
||||
|
||||
const WORD_NUMBERS: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
};
|
||||
|
||||
// A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee
|
||||
// Export". Stops at the first lowercase word ("wants", "needs", …).
|
||||
const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`;
|
||||
|
||||
// No `i` flag: the capture relies on case ([A-Z] word starts) to know where
|
||||
// the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics").
|
||||
const CUSTOMER_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`),
|
||||
new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`),
|
||||
];
|
||||
|
||||
const RECOMMEND_CREATE: AiRecommendation = {
|
||||
action: 'CREATE_DRAFT_BOOKING',
|
||||
message:
|
||||
'Booking data looks complete. User can review and create a draft booking.',
|
||||
confidence: 0.85,
|
||||
};
|
||||
|
||||
const RECOMMEND_MISSING: AiRecommendation = {
|
||||
action: 'REQUEST_MISSING_INFORMATION',
|
||||
message:
|
||||
'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.',
|
||||
confidence: 0.45,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MockAiService {
|
||||
extractBooking(text: string): AiBookingResult {
|
||||
const input = text.trim();
|
||||
|
||||
const { origin, destination } = this.extractRoute(input);
|
||||
|
||||
const extracted: AiExtractedBooking = {
|
||||
customerName: this.extractCustomerName(input),
|
||||
origin,
|
||||
destination,
|
||||
cargoType: this.extractCargoType(input),
|
||||
containerType: this.extractContainerType(input),
|
||||
quantity: this.extractQuantity(input),
|
||||
direction: this.resolveDirection(origin, destination),
|
||||
weightKg: this.extractWeightKg(input),
|
||||
pickupRequired: this.extractFlag(input, 'pickup'),
|
||||
deliveryRequired: this.extractFlag(input, 'delivery'),
|
||||
};
|
||||
|
||||
const validation = this.validate(extracted);
|
||||
|
||||
return {
|
||||
provider: 'mock',
|
||||
extracted,
|
||||
validation,
|
||||
recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING,
|
||||
};
|
||||
}
|
||||
|
||||
private extractCustomerName(text: string): string | null {
|
||||
for (const pattern of CUSTOMER_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) {
|
||||
const name = match[1].replace(/[.,;:!?]+$/, '').trim();
|
||||
if (name) return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractRoute(text: string): {
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
} {
|
||||
const fromMatch = text.match(
|
||||
new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'),
|
||||
);
|
||||
const toMatch = text.match(
|
||||
new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'),
|
||||
);
|
||||
|
||||
let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null;
|
||||
let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null;
|
||||
|
||||
if (!origin || !destination) {
|
||||
// Fall back to order of appearance ("Djibouti to Indode" without
|
||||
// "from", or a bare location mention).
|
||||
const mentions: string[] = [];
|
||||
const all = text.matchAll(
|
||||
new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'),
|
||||
);
|
||||
for (const m of all) {
|
||||
const canonical = this.canonicalLocation(m[1]);
|
||||
if (canonical && !mentions.includes(canonical)) mentions.push(canonical);
|
||||
}
|
||||
|
||||
if (!origin && !destination) {
|
||||
origin = mentions[0] ?? null;
|
||||
destination = mentions[1] ?? null;
|
||||
} else if (!origin) {
|
||||
origin = mentions.find((loc) => loc !== destination) ?? null;
|
||||
} else {
|
||||
destination = mentions.find((loc) => loc !== origin) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return { origin, destination };
|
||||
}
|
||||
|
||||
private canonicalLocation(raw: string): string | null {
|
||||
const normalized = raw.replace(/\s+/g, ' ').toLowerCase();
|
||||
return (
|
||||
KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private resolveDirection(
|
||||
origin: string | null,
|
||||
destination: string | null,
|
||||
): AiDirection | null {
|
||||
if (!origin || !destination) return null;
|
||||
if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractCargoType(text: string): string | null {
|
||||
for (const [pattern, cargo] of CARGO_KEYWORDS) {
|
||||
if (pattern.test(text)) return cargo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractContainerType(text: string): AiContainerType | null {
|
||||
// Lookbehind instead of \b: "2x40ft" has no word boundary before "40",
|
||||
// but "140ft" must not read as a 40ft container.
|
||||
if (/(?<!\d)40[\s-]?(?:ft|foot)\b/i.test(text)) return '40FT';
|
||||
if (/(?<!\d)20[\s-]?(?:ft|foot)\b/i.test(text)) return '20FT';
|
||||
if (/\bbulk\b/i.test(text)) return 'BULK';
|
||||
if (/\b(?:vehicles?|cars?)\b/i.test(text)) return 'RO_RO';
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractQuantity(text: string): number | null {
|
||||
// "2x40ft", "2 x 40ft", "3x20ft", "1x20ft"
|
||||
let match = text.match(/(\d+)\s*x\s*\d+\s*-?\s*(?:ft|foot)\b/i);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
// "one 40ft container", "two containers"
|
||||
match = text.match(
|
||||
new RegExp(
|
||||
String.raw`\b(${Object.keys(WORD_NUMBERS).join('|')})\s+(?:\d+\s*-?\s*(?:ft|foot)\s+)?containers?\b`,
|
||||
'i',
|
||||
),
|
||||
);
|
||||
if (match) return WORD_NUMBERS[match[1].toLowerCase()];
|
||||
|
||||
// "3 containers", "2 refrigerated containers"
|
||||
match = text.match(/(\d+)\s+(?:[a-z]+\s+)?containers?\b/i);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
// "5 vehicles", "3 cars"
|
||||
match = text.match(/(\d+)\s+(?:vehicles?|cars?)\b/i);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractWeightKg(text: string): number | null {
|
||||
const tons = text.match(/([\d,]+(?:\.\d+)?)\s*(?:tons?|tonnes?)\b/i);
|
||||
if (tons) return Math.round(this.parseNumber(tons[1]) * 1000);
|
||||
|
||||
const kg = text.match(/([\d,]+(?:\.\d+)?)\s*kgs?\b/i);
|
||||
if (kg) return Math.round(this.parseNumber(kg[1]));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseNumber(raw: string): number {
|
||||
return parseFloat(raw.replace(/,/g, ''));
|
||||
}
|
||||
|
||||
private extractFlag(
|
||||
text: string,
|
||||
kind: 'pickup' | 'delivery',
|
||||
): boolean | null {
|
||||
// "no pickup required" must read as false, so the negative wins.
|
||||
if (new RegExp(String.raw`\bno\s+${kind}\b`, 'i').test(text)) return false;
|
||||
if (new RegExp(String.raw`\b${kind}\s+required\b`, 'i').test(text)) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private validate(extracted: AiExtractedBooking): AiValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!extracted.customerName) errors.push('Customer name is missing');
|
||||
if (!extracted.origin) errors.push('Origin is missing');
|
||||
if (!extracted.destination) errors.push('Destination is missing');
|
||||
if (!extracted.cargoType) errors.push('Cargo type is missing');
|
||||
if (!extracted.containerType) errors.push('Container type is missing');
|
||||
if (extracted.quantity === null) errors.push('Quantity is missing');
|
||||
if (!extracted.direction) errors.push('Direction is missing');
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const AI_CONTAINER_TYPES = ['20FT', '40FT', 'BULK', 'RO_RO'] as const;
|
||||
export type AiContainerType = (typeof AI_CONTAINER_TYPES)[number];
|
||||
|
||||
export const AI_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
|
||||
export type AiDirection = (typeof AI_DIRECTIONS)[number];
|
||||
|
||||
export const AI_RECOMMENDATION_ACTIONS = [
|
||||
'CREATE_DRAFT_BOOKING',
|
||||
'REQUEST_MISSING_INFORMATION',
|
||||
] as const;
|
||||
export type AiRecommendationAction = (typeof AI_RECOMMENDATION_ACTIONS)[number];
|
||||
|
||||
export interface AiExtractedBooking {
|
||||
customerName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
cargoType: string | null;
|
||||
containerType: AiContainerType | null;
|
||||
quantity: number | null;
|
||||
direction: AiDirection | null;
|
||||
weightKg: number | null;
|
||||
pickupRequired: boolean | null;
|
||||
deliveryRequired: boolean | null;
|
||||
}
|
||||
|
||||
export interface AiValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AiRecommendation {
|
||||
action: AiRecommendationAction;
|
||||
message: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload returned by the extract endpoint. The global
|
||||
* ResponseTransformInterceptor wraps it as
|
||||
* `{ success: true, data: AiBookingResult, timestamp }` on the wire.
|
||||
*/
|
||||
export interface AiBookingResult {
|
||||
provider: 'mock';
|
||||
extracted: AiExtractedBooking;
|
||||
validation: AiValidationResult;
|
||||
recommendation: AiRecommendation;
|
||||
}
|
||||
@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
|
||||
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
|
||||
* text matrix; roughly centered on the page.
|
||||
*/
|
||||
export function watermarkOp(text: string, page: { width: number; height: number }): string {
|
||||
const label = clipText(text, 46);
|
||||
const size = 34;
|
||||
const w = textWidth(label, size);
|
||||
const x = page.width / 2 - (w * 0.866) / 2;
|
||||
const y = page.height / 2 - (w * 0.5) / 2;
|
||||
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
|
||||
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
|
||||
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
|
||||
* document, not a flat text dump. Switches to landscape when the table is wide.
|
||||
*/
|
||||
export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
// Documents printed in duplicate wrap each copy in <section class="copy">
|
||||
// (freight order: Port Operations copy + Gate Security copy). Render one
|
||||
// page per copy, each with its own watermark and tile set — parsing the
|
||||
// whole HTML at once would merge both copies' tiles and drop the watermarks.
|
||||
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
|
||||
const fragments = copies.length ? copies : [html];
|
||||
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
|
||||
}
|
||||
|
||||
function buildTabularPageOps(
|
||||
html: string,
|
||||
): Array<{ ops: string[]; page: { width: number; height: number } }> {
|
||||
const pick = (re: RegExp) => html.match(re)?.[1];
|
||||
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
||||
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
|
||||
const metaLabel =
|
||||
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
|
||||
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
||||
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
|
||||
const tiles: Array<[string, string]> = [];
|
||||
for (const m of html.matchAll(
|
||||
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
const M = 32;
|
||||
const contentW = page.width - M * 2;
|
||||
const right = page.width - M;
|
||||
const ops: string[] = [];
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
// Header
|
||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
|
||||
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
|
||||
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
|
||||
if (metaRef) {
|
||||
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
|
||||
}
|
||||
if (generated) {
|
||||
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
|
||||
}
|
||||
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
|
||||
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
|
||||
let ops: string[] = [];
|
||||
let y = 0;
|
||||
|
||||
// Summary tiles
|
||||
let y = page.height - 100;
|
||||
const drawFullHeader = () => {
|
||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
|
||||
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
|
||||
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
|
||||
if (metaRef) {
|
||||
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
|
||||
}
|
||||
if (generated) {
|
||||
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
|
||||
}
|
||||
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
|
||||
y = page.height - 100;
|
||||
};
|
||||
|
||||
const drawContinuationHeader = (pageNo: number) => {
|
||||
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
|
||||
ops.push(
|
||||
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
|
||||
);
|
||||
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
|
||||
y = page.height - 54;
|
||||
};
|
||||
|
||||
const startPage = (first: boolean) => {
|
||||
ops = [];
|
||||
if (watermark) ops.push(watermarkOp(watermark, page));
|
||||
if (first) drawFullHeader();
|
||||
else drawContinuationHeader(pagesOut.length + 1);
|
||||
};
|
||||
|
||||
const finishPage = () => pagesOut.push({ ops, page });
|
||||
|
||||
startPage(true);
|
||||
|
||||
// Summary tiles (first page only)
|
||||
if (tiles.length) {
|
||||
const cols = landscape ? 6 : 4;
|
||||
const tileW = contentW / cols;
|
||||
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
y -= tileH + 12;
|
||||
}
|
||||
|
||||
// Table
|
||||
// Table, paginated across as many pages as the rows need.
|
||||
if (headers.length) {
|
||||
const colW = contentW / headers.length;
|
||||
const headerH = 16;
|
||||
const rowH = 14;
|
||||
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||
headers.forEach((h, c) =>
|
||||
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
|
||||
);
|
||||
y -= headerH;
|
||||
const bottomReserve = 46; // keep clear of the page edge on row-only pages
|
||||
|
||||
let shown = 0;
|
||||
for (const row of rows) {
|
||||
if (y < 96) break;
|
||||
const drawTableHeader = () => {
|
||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||
headers.forEach((h, c) =>
|
||||
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
|
||||
);
|
||||
y -= headerH;
|
||||
};
|
||||
|
||||
drawTableHeader();
|
||||
let truncated = 0;
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (y - rowH < bottomReserve) {
|
||||
if (pagesOut.length + 1 >= MAX_PAGES) {
|
||||
truncated = rows.length - index;
|
||||
break;
|
||||
}
|
||||
finishPage();
|
||||
startPage(false);
|
||||
drawTableHeader();
|
||||
}
|
||||
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
|
||||
headers.forEach((_h, c) => {
|
||||
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
||||
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
||||
});
|
||||
y -= rowH;
|
||||
shown += 1;
|
||||
}
|
||||
if (shown < rows.length) {
|
||||
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
||||
if (truncated > 0) {
|
||||
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
||||
}
|
||||
}
|
||||
|
||||
// Notice (verification clause)
|
||||
// Notice + signatures live on the final page; give them a fresh page when the
|
||||
// rows ran too deep for the fixed bottom band.
|
||||
if (y < 110 && (notice || signatures.length)) {
|
||||
finishPage();
|
||||
startPage(false);
|
||||
}
|
||||
if (notice) {
|
||||
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
||||
wrapText(notice, landscape ? 155 : 104)
|
||||
.slice(0, 2)
|
||||
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
||||
}
|
||||
|
||||
// Signatures
|
||||
const sigW = contentW / signatures.length;
|
||||
signatures.forEach((s, i) => {
|
||||
signatures.forEach((sig, i) => {
|
||||
const x = M + i * sigW;
|
||||
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
||||
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||
});
|
||||
finishPage();
|
||||
|
||||
return assembleSinglePagePdf(ops, page);
|
||||
return pagesOut;
|
||||
}
|
||||
|
||||
/** Greedy word-wrap to a maximum character width. */
|
||||
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
|
||||
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
|
||||
export function assemblePdf(
|
||||
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
|
||||
): Buffer {
|
||||
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
|
||||
const objects: string[] = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
||||
];
|
||||
for (const [i, p] of pages.entries()) {
|
||||
const stream = p.ops.join("\n");
|
||||
objects.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
|
||||
);
|
||||
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
|
||||
}
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
|
||||
pdf += "% fallback padding\n";
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||
};
|
||||
|
||||
// Input set has two required docs.
|
||||
// Input set has two required docs. Non-customs bookings resolve to the
|
||||
// ONE_TIME self-clearance document set.
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', isRequired: true },
|
||||
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
*/
|
||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||
|
||||
@@ -988,6 +988,15 @@ export class BookingTransitionService {
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
// A bare initiated instance (clearance-first flow) carries no cargo or
|
||||
// price — it must go through the contract completion endpoint, which
|
||||
// persists cargo, prices, invoices and only then lands here itself.
|
||||
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"This booking must be completed (cargo and shipment day) before requesting operation.",
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
@@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
@@ -116,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
|
||||
@@ -7,6 +7,7 @@ function mockQueryBuilder() {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
getCount: jest.fn().mockResolvedValue(0),
|
||||
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -605,6 +605,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
async findAllPaginated(options: BookingListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{
|
||||
@@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search spans joined columns (company, contract) that only this
|
||||
// list query joins — so it lives here, not in applyListFilters (shared
|
||||
// with getListSummaryMetrics, whose query builder has no joins).
|
||||
if (options.search) {
|
||||
qb.andWhere(
|
||||
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
|
||||
{ search: `%${options.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (options.sortBy === 'isGovernment') {
|
||||
qb.orderBy('booking.isGovernment', 'DESC')
|
||||
.addOrderBy('booking.priorityScore', 'DESC')
|
||||
@@ -804,9 +815,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.bookingType = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||
// drawdowns included — see contract-booking.service create), so the
|
||||
// one-time vs general split keys on the denormalized contract_kind:
|
||||
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
|
||||
// = everything else (ONE_TIME contracts and legacy contract-less rows).
|
||||
if (options.bookingType === 'GENERAL_CONTRACT') {
|
||||
qb.andWhere("booking.contract_kind = 'GENERAL'");
|
||||
} else {
|
||||
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
|
||||
}
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
@@ -1251,7 +1269,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
fields: Partial<
|
||||
Pick<
|
||||
Booking,
|
||||
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
||||
| 'schedulingStatus'
|
||||
| 'wagonsRequired'
|
||||
| 'scheduledAt'
|
||||
| 'holdStartedAt'
|
||||
| 'holdExpiresAt'
|
||||
| 'trainScheduleId'
|
||||
>
|
||||
>,
|
||||
manager?: EntityManager,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
@@ -50,7 +51,8 @@ import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -99,7 +101,7 @@ export class BookingsService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
private readonly pdfRender: PdfRenderService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
@@ -170,7 +172,12 @@ export class BookingsService {
|
||||
);
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||
// generic text dump — the freight order is an outward-facing gate document).
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'freight order',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
});
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
@@ -262,21 +269,10 @@ export class BookingsService {
|
||||
containers: string | null;
|
||||
}>,
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const bookingRows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const bookingRowHtml = bookingRows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
// Fall back to the legacy single-truck booking columns when there are no
|
||||
// multi-truck rows (bookings assigned before the multi-truck feature).
|
||||
@@ -297,44 +293,63 @@ export class BookingsService {
|
||||
]
|
||||
: [];
|
||||
|
||||
const truckBlocks = truckList
|
||||
.map((t, i) => {
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Truck Plate Number', t.plateNumber],
|
||||
['Driver Name', t.driverName],
|
||||
['Truck Type', t.truckType],
|
||||
['Containers Loaded', t.containers],
|
||||
[
|
||||
'Arrival',
|
||||
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
|
||||
],
|
||||
];
|
||||
const html = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
||||
})
|
||||
const truckRows = truckList
|
||||
.map(
|
||||
(t, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(t.plateNumber)}</td>
|
||||
<td>${esc(t.driverName)}</td>
|
||||
<td>${esc(t.truckType)}</td>
|
||||
<td>${esc(t.containers)}</td>
|
||||
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const copy = (watermark: string) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div class="watermark">${esc(watermark)}</div>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${bookingRowHtml}</table>
|
||||
${truckBlocks}
|
||||
<div class="meta">
|
||||
Booking
|
||||
<strong>${esc(booking.reference)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
|
||||
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
|
||||
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
|
||||
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
|
||||
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th>
|
||||
<th>Truck plate</th>
|
||||
<th>Driver</th>
|
||||
<th>Truck type</th>
|
||||
<th>Containers loaded</th>
|
||||
<th>Arrival</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${truckRows}</tbody>
|
||||
</table>
|
||||
<div class="notice">
|
||||
Present this freight order at the warehouse gate. Each truck may only collect the
|
||||
containers listed against it; the handover must be signed before any truck leaves.
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</div>
|
||||
<div>Port Operations Verification</div>
|
||||
<div>Gate Security Verification</div>
|
||||
<div class="line">Customer / Carrier signature — date</div>
|
||||
<div class="line">Port operations verification — date</div>
|
||||
<div class="line">Gate security verification — date</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
@@ -342,21 +357,30 @@ export class BookingsService {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Freight Order</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.truck { page-break-inside: avoid; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
@page { size: A4 portrait; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
|
||||
.num { text-align: right; width: 26px; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1188,6 +1212,13 @@ export class BookingsService {
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
// DTO carries 'true'/'false' strings (query params); the repo option is a
|
||||
// real boolean — convert, preserving "not filtered" when absent.
|
||||
customsClearingEnabled:
|
||||
filter.customsClearingEnabled === undefined
|
||||
? undefined
|
||||
: filter.customsClearingEnabled === 'true',
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1239,6 +1270,7 @@ export class BookingsService {
|
||||
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||
// reviewed by Marketing from the booking detail, not this queue.
|
||||
customsClearingEnabled: true,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1263,6 +1295,7 @@ export class BookingsService {
|
||||
// Company-wide: payables span all of the customer's services.
|
||||
companyId: company.id,
|
||||
companyProfileId: filter.companyProfileId,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1450,6 +1483,18 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the parent contract's reference for drawdown bookings — the
|
||||
// portal detail header shows it (the entity has no contract relation, so
|
||||
// the list attaches it via a raw join and the detail attaches it here).
|
||||
if (booking.contractId) {
|
||||
const contract = await this.dataSource.getRepository(Contract).findOne({
|
||||
where: { id: booking.contractId },
|
||||
select: { reference: true },
|
||||
});
|
||||
(booking as Booking & { contractReference?: string | null }).contractReference =
|
||||
contract?.reference ?? null;
|
||||
}
|
||||
|
||||
// Surface the assigned train's operational status so the portal stepper
|
||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||
|
||||
@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_import_container_with_customs',
|
||||
);
|
||||
// Non-customs bookings self-clear with the same document set a ONE_TIME
|
||||
// self-clear contract uses.
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||
'clearance_import_container_without_customs',
|
||||
'contract_clearance_selfclear_import_container',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
'clearance_export_bulk_with_customs',
|
||||
);
|
||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||
'clearance_export_bulk_without_customs',
|
||||
'contract_clearance_selfclear_export_bulk',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,14 @@ export function clearanceSettingCode(
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
||||
return `clearance_${op}_${freight}_${customs}`;
|
||||
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
||||
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
||||
// contract uses (customs declaration, release permit, …) — not the
|
||||
// GL-oriented booking sets.
|
||||
if (!includesCustoms) {
|
||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||
}
|
||||
return `clearance_${op}_${freight}_with_customs`;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
||||
|
||||
@@ -2,18 +2,24 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
|
||||
interface BookingGuardRow {
|
||||
tradeDirection: string | null;
|
||||
freightType: string | null;
|
||||
firstMile: string | null;
|
||||
lastMile: string | null;
|
||||
paymentStatus: string | null;
|
||||
@@ -29,9 +35,13 @@ interface BookingGuardRow {
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerTruckService {
|
||||
private readonly logger = new Logger(CustomerTruckService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||
@@ -41,14 +51,20 @@ export class CustomerTruckService {
|
||||
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
this.assertAssignmentWindow(booking);
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
// Bulk bookings have no containers — the truck hauls loose tonnage and is
|
||||
// weighed out on departure (gross_weight_kg). Container bookings assign the
|
||||
// 1–2 specific containers each truck carries.
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
const requested = isBulk
|
||||
? []
|
||||
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
|
||||
// Both import and export specify the containers each truck carries. Capacity
|
||||
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
|
||||
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
|
||||
// each container is assigned to exactly one truck.
|
||||
if (requested.length < 1) {
|
||||
// Container capacity is size-based: a 40ft container fills the truck (max 1);
|
||||
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
|
||||
// follows naturally since each container is assigned to exactly one truck.
|
||||
if (!isBulk && requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
@@ -395,20 +411,74 @@ export class CustomerTruckService {
|
||||
});
|
||||
if (!container) return;
|
||||
|
||||
const assignment = await m
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.findOne({ where: { id: container.assignmentId } });
|
||||
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
|
||||
|
||||
await m
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||
|
||||
await this.syncBookingArrival(bookingId, m);
|
||||
|
||||
if (justArrived && assignment) {
|
||||
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark every truck on the booking arrived (fallback when no container is known). */
|
||||
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||
const m = manager ?? this.dataSource.manager;
|
||||
const justArrived = await m
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.find({ where: { bookingId, arrivedAt: IsNull() } });
|
||||
await m
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||
await this.syncBookingArrival(bookingId, m);
|
||||
for (const truck of justArrived) {
|
||||
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-arrival notification to the booking's company across every
|
||||
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
* provider or contact must not break the arrival flow.
|
||||
*/
|
||||
private async notifyTruckArrival(
|
||||
bookingId: string,
|
||||
plateNumber: string | null,
|
||||
m: EntityManager,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await m.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
|
||||
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck arrived',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,6 +500,7 @@ export class CustomerTruckService {
|
||||
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
|
||||
const [row]: BookingGuardRow[] = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection",
|
||||
freight_type AS "freightType",
|
||||
first_mile_pickup_address AS "firstMile",
|
||||
last_mile_delivery_address AS "lastMile",
|
||||
payment_status AS "paymentStatus",
|
||||
@@ -463,6 +534,30 @@ export class CustomerTruckService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment window by direction:
|
||||
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
|
||||
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
|
||||
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
|
||||
* (IN_TRANSIT and beyond) assignment is closed.
|
||||
*/
|
||||
private assertAssignmentWindow(booking: BookingGuardRow): void {
|
||||
const status = booking.status ?? '';
|
||||
if (booking.tradeDirection === 'IMPORT') {
|
||||
if (status !== 'ARRIVED') {
|
||||
throw new BadRequestException(
|
||||
'Import pickup trucks can only be assigned after the train has arrived',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
|
||||
throw new BadRequestException(
|
||||
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber"
|
||||
|
||||
@@ -106,6 +106,14 @@ export class FilterBookingDto {
|
||||
@IsIn(['true', 'false'])
|
||||
isGovernment?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['true', 'false'],
|
||||
description: 'Filter customs vs self-clearance (non-customs) bookings',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
customsClearingEnabled?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
@@ -125,6 +133,16 @@ export class FilterBookingDto {
|
||||
@IsOptional()
|
||||
consolidationPaired?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Free-text search across booking reference, company name, and contract reference.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
|
||||
@@ -166,6 +166,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||
contractKind?: string | null;
|
||||
|
||||
/**
|
||||
* The customer paid a partial batch offer and this booking was reduced to the
|
||||
* offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a
|
||||
* split booking releases the single-active-booking slot for the remainder —
|
||||
* the contract kind itself is never changed.
|
||||
*/
|
||||
@Column({ name: 'is_split', type: 'boolean', default: false })
|
||||
isSplit!: boolean;
|
||||
|
||||
/**
|
||||
* Quantities this booking carried BEFORE it was reduced by a split — the
|
||||
* split chain's source of truth for the outstanding remainder (ONE_TIME
|
||||
* contracts have no quantity cap to derive it from). Bulk: total tons;
|
||||
* container: units per size. Null until the booking is split.
|
||||
*/
|
||||
@Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true })
|
||||
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
|
||||
|
||||
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||
createdByRole?: string | null;
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||
import {
|
||||
CompanyDocumentFileView,
|
||||
ProfileLicenseFileView,
|
||||
} from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@@ -218,7 +221,7 @@ export class CompaniesController {
|
||||
@Post("company-profile")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a single operational profile for the current user's company and make it the active mode",
|
||||
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
|
||||
})
|
||||
async createCompanyProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -306,6 +309,49 @@ export class CompaniesController {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@Get("poa-delegation")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List the Power of Attorney delegation letter (with review state) for the current user's company",
|
||||
})
|
||||
async listPoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
return this.companiesService.listPoaDelegationFiles(user.id);
|
||||
}
|
||||
|
||||
@Post("poa-delegation")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
|
||||
"For an approved company the upload is staged for backoffice review; during " +
|
||||
"onboarding it goes live.",
|
||||
})
|
||||
async uploadPoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const file = files?.[0];
|
||||
if (!file) {
|
||||
throw new BadRequestException("A delegation letter file is required");
|
||||
}
|
||||
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
|
||||
}
|
||||
|
||||
@Delete("poa-delegation/:fileId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
|
||||
})
|
||||
async removePoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
MinioModule,
|
||||
// Account-status notifications (CompanyNotifierService). The inbox module
|
||||
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
CompanyNotifierService,
|
||||
],
|
||||
exports: [
|
||||
CompaniesService,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyDocumentFileView,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
ProfileType,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
@@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
||||
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
/** Code for a PoA letter staged in an open change request (not yet live). */
|
||||
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
/** FileRecord resource that company-level documents are stored under. */
|
||||
const COMPANY_RESOURCE = "companies";
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_ATTRIBUTES = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
/** Mandatory once the company operates as a freight forwarder. */
|
||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaName", label: "PoA name" },
|
||||
{ key: "poaEmail", label: "PoA email" },
|
||||
{ key: "poaPhone", label: "PoA phone" },
|
||||
];
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
@@ -73,6 +97,7 @@ export class CompaniesService {
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -562,9 +587,13 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const before = await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
|
||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||
// This is the only path that writes those statuses.
|
||||
this.companyNotifier.statusChanged(updated, before.status);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -765,6 +794,7 @@ export class CompaniesService {
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
@@ -817,7 +847,12 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { documentFileIds: [...prev, ...fileIds] },
|
||||
// Spread the existing documents blob: a bare object would drop any
|
||||
// licenseChanges/documentChanges already staged on this request.
|
||||
documents: {
|
||||
...existing.documents,
|
||||
documentFileIds: [...prev, ...fileIds],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
@@ -849,12 +884,17 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
await this.discardLicenseChanges(request);
|
||||
await this.discardDocumentChanges(request);
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
// Staged license uploads were just discarded; drop their intents so an
|
||||
// amended resubmit never re-references deleted files.
|
||||
documents: { ...request.documents, licenseChanges: [] },
|
||||
// Staged license/document uploads were just discarded; drop their intents
|
||||
// so an amended resubmit never re-references deleted files.
|
||||
documents: {
|
||||
...request.documents,
|
||||
licenseChanges: [],
|
||||
documentChanges: [],
|
||||
},
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
@@ -1017,13 +1057,12 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
|
||||
// No reference is minted here: it is issued by setCompanyProfileStatus when
|
||||
// a reviewer approves the role. Creating it Active would bypass that review.
|
||||
return this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1097,9 +1136,11 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single operational profile for the current user's company and
|
||||
* make it the active mode in the same call. Powers the header "Switch to
|
||||
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
||||
* Create a single operational profile for the current user's company. The new
|
||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||
* block them from creating contracts under the role they already had approved.
|
||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
@@ -1122,8 +1163,7 @@ export class CompaniesService {
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created) {
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved. The customer can select this mode but
|
||||
// can't book under it until it's cleared.
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
@@ -1132,8 +1172,6 @@ export class CompaniesService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1278,29 @@ export class CompaniesService {
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
||||
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
||||
// has been entered must be evidenced by the delegation letter.
|
||||
const poaRequired = (company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
const missingPoaFields = poaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
// Only gate on the letter once the document set actually carries the field.
|
||||
const delegationField = (setting?.fields ?? []).find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
const missingDelegation =
|
||||
Boolean(delegationField) &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||
@@ -1247,18 +1308,31 @@ export class CompaniesService {
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? ["Upload the delegation letter for your Power of Attorney"]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Progress spans every required item the user has to satisfy: company-info
|
||||
// fields, required documents and one license per operational profile.
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/letter whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
||||
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
||||
const total =
|
||||
this.REQUIRED_COMPANY_INFO.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length;
|
||||
licenseProfiles.length +
|
||||
poaItemCount;
|
||||
const completed =
|
||||
total -
|
||||
(missingInfo.length + missingDocs.length + missingLicenses.length);
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation ? 1 : 0));
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
@@ -1266,6 +1340,13 @@ export class CompaniesService {
|
||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||
documents,
|
||||
licenseProfiles,
|
||||
poa: {
|
||||
required: poaRequired,
|
||||
provided: poaProvided,
|
||||
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
||||
missingFields: missingPoaFields,
|
||||
complete: missingPoaFields.length === 0 && !missingDelegation,
|
||||
},
|
||||
progress: { completed, total },
|
||||
isComplete: outstanding.length === 0,
|
||||
onboardingCompleted: profile.onboardingCompleted,
|
||||
@@ -1365,10 +1446,12 @@ export class CompaniesService {
|
||||
// browser (which fails on the internal bucket endpoint).
|
||||
|
||||
/**
|
||||
* Upload business-license file(s) for one of the user's profiles. During
|
||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
||||
* company they're staged under the pending code and recorded as `add` intents
|
||||
* on a pending change request for backoffice review. Returns the updated view.
|
||||
* Upload business-license file(s) for one of the user's profiles. For a role
|
||||
* not yet approved (a fresh onboarding profile, or a newly added service on an
|
||||
* already-active company) they go live immediately and are reviewed together
|
||||
* with the role itself. Only for an already-approved role are they staged under
|
||||
* the pending code and recorded as `add` intents on a pending change request —
|
||||
* a licence swap on a live role is a change; a licence on a new role is not.
|
||||
*/
|
||||
async addProfileLicenseFiles(
|
||||
userId: string,
|
||||
@@ -1377,7 +1460,7 @@ export class CompaniesService {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const gated = profile.status === ProfileStatus.Active;
|
||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||
|
||||
const uploaded = await Promise.all(
|
||||
@@ -1409,9 +1492,9 @@ export class CompaniesService {
|
||||
|
||||
/**
|
||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
||||
* is deleted immediately.
|
||||
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
|
||||
* role is kept and recorded as a `remove` intent for review; on a role still
|
||||
* awaiting approval it is deleted immediately.
|
||||
*/
|
||||
async removeProfileLicenseFile(
|
||||
userId: string,
|
||||
@@ -1427,7 +1510,7 @@ export class CompaniesService {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const gated = profile.status === ProfileStatus.Active;
|
||||
|
||||
if (record.code === LICENSE_PENDING_CODE) {
|
||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||
@@ -1449,7 +1532,7 @@ export class CompaniesService {
|
||||
/**
|
||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||
* atomically. During onboarding the swap is applied immediately.
|
||||
* atomically. On a role still awaiting approval the swap is applied immediately.
|
||||
*/
|
||||
async replaceProfileLicenseFile(
|
||||
userId: string,
|
||||
@@ -1463,7 +1546,7 @@ export class CompaniesService {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const gated = profile.status === ProfileStatus.Active;
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
@@ -1671,6 +1754,254 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Power of Attorney delegation letter
|
||||
//
|
||||
// A company-level document that follows the same staged-review model as the
|
||||
// business license: on an approved (Active) company an upload lands under the
|
||||
// pending code and the live letter is flagged for removal, so the reviewer
|
||||
// sees both and approval swaps them atomically. During onboarding it goes live.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The company's PoA letter(s), with each file's review status resolved. */
|
||||
async listPoaDelegationFiles(
|
||||
userId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the PoA delegation letter, replacing whatever is already on file.
|
||||
* On an Active company this stages an `add` for the new file plus a `remove`
|
||||
* for each live one; a letter still awaiting approval is withdrawn outright
|
||||
* rather than stacking a second pending upload.
|
||||
*/
|
||||
async uploadPoaDelegationLetter(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
const records = await this.filesService.findByResource(
|
||||
company.id,
|
||||
COMPANY_RESOURCE,
|
||||
);
|
||||
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
|
||||
const staged = records.filter(
|
||||
(r) => r.code === POA_DELEGATION_PENDING_CODE,
|
||||
);
|
||||
|
||||
// Supersede an unreviewed upload instead of queueing another one.
|
||||
for (const r of staged) {
|
||||
await this.filesService.remove(r.id);
|
||||
await this.withdrawDocumentIntent(company.id, r.id);
|
||||
}
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: company.id,
|
||||
resource: COMPANY_RESOURCE,
|
||||
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
|
||||
file,
|
||||
});
|
||||
|
||||
if (gated) {
|
||||
await this.stageDocumentIntent(
|
||||
company.id,
|
||||
[
|
||||
...live.map((r) => ({
|
||||
op: "remove" as const,
|
||||
fileId: r.id,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: r.name,
|
||||
})),
|
||||
{
|
||||
op: "add" as const,
|
||||
fileId: created.id,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: created.name,
|
||||
},
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// Onboarding: no review, so the old letter is simply replaced.
|
||||
for (const r of live) await this.filesService.remove(r.id);
|
||||
}
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
|
||||
* an Active company is kept and flagged for deletion on approval; during
|
||||
* onboarding it is deleted immediately.
|
||||
*/
|
||||
async removePoaDelegationLetter(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
if (
|
||||
record.resource !== COMPANY_RESOURCE ||
|
||||
record.resourceId !== company.id ||
|
||||
(record.code !== POA_DELEGATION_FILE_KEY &&
|
||||
record.code !== POA_DELEGATION_PENDING_CODE)
|
||||
) {
|
||||
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
||||
}
|
||||
|
||||
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawDocumentIntent(company.id, fileId);
|
||||
} else if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentIntent(
|
||||
company.id,
|
||||
[
|
||||
{
|
||||
op: "remove",
|
||||
fileId,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: record.name,
|
||||
},
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
private async getPoaDelegationView(
|
||||
companyId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.documentChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
const records = await this.filesService.findByResource(
|
||||
companyId,
|
||||
COMPANY_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) =>
|
||||
r.code === POA_DELEGATION_FILE_KEY ||
|
||||
r.code === POA_DELEGATION_PENDING_CODE,
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
status:
|
||||
r.code === POA_DELEGATION_PENDING_CODE
|
||||
? ("pending_add" as const)
|
||||
: removeIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording document add/remove intents. */
|
||||
private async stageDocumentIntent(
|
||||
companyId: string,
|
||||
changes: DocumentChangeIntent[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentChanges ?? [];
|
||||
// Re-uploading twice before review would otherwise stage a second `remove`
|
||||
// for the same live file, and the duplicate would fail on approval.
|
||||
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
|
||||
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
|
||||
if (fresh.length === 0) return;
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: {
|
||||
...existing.documents,
|
||||
documentChanges: [...prev, ...fresh],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentChanges: changes },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a staged document intent referencing `fileId`. If that empties the
|
||||
* request entirely, delete it so the customer's settings page unlocks.
|
||||
*/
|
||||
private async withdrawDocumentIntent(
|
||||
companyId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (!existing) return;
|
||||
const remaining = (existing.documents?.documentChanges ?? []).filter(
|
||||
(c) => c.fileId !== fileId,
|
||||
);
|
||||
const docs = existing.documents ?? {};
|
||||
const stillHasWork =
|
||||
remaining.length > 0 ||
|
||||
(docs.licenseChanges?.length ?? 0) > 0 ||
|
||||
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||
|
||||
if (stillHasWork) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { ...docs, documentChanges: remaining },
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.softDelete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a request's staged document changes: promote adds, delete removes. */
|
||||
private async applyDocumentChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.documentChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.setCode(change.fileId, change.code);
|
||||
} else {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard a rejected request's staged document uploads (adds only). */
|
||||
private async discardDocumentChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.documentChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
@@ -1719,13 +2050,17 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
if (!businessInfo) {
|
||||
throw new BadRequestException(
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
||||
const registrationData = this.etradeService.extractRegistrationData(
|
||||
businessInfo,
|
||||
companyInfo,
|
||||
);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationPriority,
|
||||
NotificationType,
|
||||
} from "@edr/types";
|
||||
|
||||
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
|
||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||
CompanyStatus.Suspended,
|
||||
CompanyStatus.Blacklisted,
|
||||
];
|
||||
|
||||
/**
|
||||
* Customer notifications for company account-status changes. Mirrors
|
||||
* {@link ContractNotifierService}: SMS + email direct to the company contact,
|
||||
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
|
||||
* a notification failure must not roll back the status change itself.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompanyNotifierService {
|
||||
private readonly logger = new Logger(CompanyNotifierService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/** Send SMS + email to the company contact; log-only on failure. */
|
||||
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||
const phone = company.contactPersonPhone ?? company.phone ?? null;
|
||||
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend("sms", phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend("email", email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${company.id} — not notified`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
||||
* a real transition into one of those statuses; other status writes are silent.
|
||||
*/
|
||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||
const status = company.status;
|
||||
if (status === previous) return;
|
||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||
|
||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||
const title = `Account ${label}`;
|
||||
const body =
|
||||
`Your company account has been ${label}. ` +
|
||||
`You will not be able to submit new contracts or bookings. ` +
|
||||
`Please contact EDR support for assistance.`;
|
||||
|
||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.ACCOUNT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, status },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "../entities/company-change-request.entity";
|
||||
|
||||
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges: DocumentChangeIntent[];
|
||||
note: string | null;
|
||||
submittedBy: string | null;
|
||||
submittedAt: Date | null;
|
||||
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
|
||||
this.snapshot = req.snapshot ?? {};
|
||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||
this.documentChanges = req.documents?.documentChanges ?? [];
|
||||
this.note = req.note ?? null;
|
||||
this.submittedBy = req.submittedBy ?? null;
|
||||
this.submittedAt = req.submittedAt ?? null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
companyName!: string;
|
||||
licenceNumber!: string;
|
||||
statusDescription!: string;
|
||||
dateRegistered!: string;
|
||||
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
tinTaken?: boolean;
|
||||
|
||||
constructor(data: CompanyRegistrationData) {
|
||||
this.companyName = data.companyName;
|
||||
this.licenceNumber = data.licenceNumber;
|
||||
this.statusDescription = data.statusDescription;
|
||||
this.dateRegistered = data.dateRegistered;
|
||||
|
||||
@@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile {
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
/** True when the company operates as a freight forwarder — PoA is mandatory. */
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/** True when the delegation letter is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
/** PoA details still missing (only populated when `required`). */
|
||||
missingFields: OnboardingInfoField[];
|
||||
/** False while the PoA step still owes details or a delegation letter. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export class OnboardingRequirementsResponseDto {
|
||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||
documentSettingCode: string;
|
||||
@@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto {
|
||||
/** Per-operational-profile business-license requirements. */
|
||||
licenseProfiles: OnboardingLicenseProfile[];
|
||||
|
||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||
poa: OnboardingPoaState;
|
||||
|
||||
/** Overall setup progress across fields + documents + licenses. */
|
||||
progress: { completed: number; total: number };
|
||||
|
||||
@@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.companyInfo = init.companyInfo;
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
this.poa = init.poa;
|
||||
this.progress = init.progress;
|
||||
this.isComplete = init.isComplete;
|
||||
this.onboardingCompleted = init.onboardingCompleted;
|
||||
|
||||
@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged change to a company-level document, awaiting review. Same semantics
|
||||
* as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code`
|
||||
* (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under
|
||||
* the pending code, promoted to `code` on approval; `remove` → a live file that
|
||||
* is deleted on approval. A replace is a `remove` plus an `add`.
|
||||
*/
|
||||
export interface DocumentChangeIntent {
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
/** The live FileRecord code this op targets (the upload setting's fileKey). */
|
||||
code: string;
|
||||
/** File name, snapshotted for the backoffice review screen. */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** File references staged alongside a change request (documents/licenses). */
|
||||
export interface ChangeRequestDocuments {
|
||||
/** FileRecord ids uploaded against the company while this request was open. */
|
||||
/**
|
||||
* FileRecord ids uploaded against the company while this request was open.
|
||||
* These go live immediately — only their ids are recorded, for the reviewer.
|
||||
* Contrast `documentChanges`, which stages the file behind the pending code.
|
||||
*/
|
||||
documentFileIds?: string[];
|
||||
/** Staged per-profile business-license add/remove intents. */
|
||||
licenseChanges?: LicenseChangeIntent[];
|
||||
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges?: DocumentChangeIntent[];
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_change_request" })
|
||||
|
||||
@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
status: "live" | "pending_add" | "pending_remove";
|
||||
status: StagedFileStatus;
|
||||
}
|
||||
|
||||
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||
export interface CompanyDocumentFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
status: StagedFileStatus;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@@ -73,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
|
||||
})
|
||||
reference!: string | null;
|
||||
|
||||
/**
|
||||
* A newly requested operational role is unreviewed, so it defaults to Pending.
|
||||
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
|
||||
* Active — an approved-by-default role would let a customer self-grant a
|
||||
* service (e.g. importer) without any documentation review.
|
||||
*/
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: ProfileStatus.Active,
|
||||
default: ProfileStatus.Pending,
|
||||
})
|
||||
status!: ProfileStatus;
|
||||
|
||||
|
||||
@@ -87,12 +87,21 @@ export class ETradeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
companyInfo?: ETradeCompanyInfo,
|
||||
): CompanyRegistrationData {
|
||||
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
|
||||
@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
});
|
||||
}
|
||||
|
||||
/** GL queue: pending requests across all contracts, oldest first. */
|
||||
async findPending(): Promise<BookingRequest[]> {
|
||||
/**
|
||||
* GL queue: every request across all contracts, newest first. The queue page
|
||||
* filters by status client-side (pending work vs accepted/rejected history),
|
||||
* and surfaces the customer — so the contract's company rides along.
|
||||
*/
|
||||
async findQueue(): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { status: 'PENDING' },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: { contract: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: { contract: { company: true } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,17 @@ export class BookingRequestService {
|
||||
},
|
||||
};
|
||||
|
||||
// Clearance-first flow: the request immediately initiates a BARE booking
|
||||
// instance (no cargo, no date, no price) that enters per-booking phased
|
||||
// customs clearance. GL no longer screens the request up front — it
|
||||
// reviews the documents in the clearance queue and completes the booking
|
||||
// (container numbers, VGM, shipment day) once clearance is ready. The
|
||||
// instance is created first so a failure leaves no half-linked request.
|
||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||
contract,
|
||||
{ contractRouteId: dto.contractRouteId, userId },
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const request = await this.repo.create({
|
||||
reference,
|
||||
@@ -120,7 +131,8 @@ export class BookingRequestService {
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: 'PENDING',
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: booking.id,
|
||||
requestedLines,
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
@@ -139,7 +151,7 @@ export class BookingRequestService {
|
||||
}
|
||||
|
||||
queue(): Promise<BookingRequest[]> {
|
||||
return this.repo.findPending();
|
||||
return this.repo.findQueue();
|
||||
}
|
||||
|
||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||
@@ -187,6 +199,8 @@ export class BookingRequestService {
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
const contract = await this.contractsService.findById(request.contractId);
|
||||
this.notifier.shipmentRequestRejected(contract, request.reference, note);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,31 @@ export class ClearanceMilestoneService {
|
||||
await this.seed(postBooking, { bookingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed whichever pre/post-booking milestones the booking is still missing,
|
||||
* keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
|
||||
* run more than once (completing an initiated instance whose pre-booking
|
||||
* milestones were seeded at initiation, or a consolidation pairing replay)
|
||||
* must go through this instead — a duplicate timeline breaks the phase
|
||||
* derivation.
|
||||
*/
|
||||
async ensureBookingMilestones(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.repo.find({ where: { bookingId } });
|
||||
const have = new Set(existing.map((m) => m.milestoneCode));
|
||||
const { preBooking, postBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(
|
||||
preBooking.filter((d) => !have.has(d.code)),
|
||||
{ bookingId },
|
||||
);
|
||||
await this.seed(
|
||||
postBooking.filter((d) => !have.has(d.code)),
|
||||
{ bookingId },
|
||||
);
|
||||
}
|
||||
|
||||
private async seed(
|
||||
defs: MilestoneDef[],
|
||||
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||
|
||||
@@ -28,6 +28,8 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // invoiceService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
const milestoneService = {
|
||||
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
||||
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.milestoneService,
|
||||
};
|
||||
const contractsRepository = {
|
||||
@@ -58,6 +59,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
@@ -143,9 +146,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||
// GENERAL customs → per-booking pre + post milestones.
|
||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
||||
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
|
||||
// pairing replay (or an initiated instance's pre-seeded timeline) never
|
||||
// duplicates rows.
|
||||
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'EXPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -866,12 +866,38 @@ export class ContractClearanceService {
|
||||
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||
* review of the customer's own clearance documents.
|
||||
*/
|
||||
/**
|
||||
* Statuses a non-customs contract passes through around Operations
|
||||
* clearance review — the set a caller may narrow {@link opsQueue} to.
|
||||
*/
|
||||
private static readonly OPS_CLEARANCE_STATUSES = [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'CONTRACT_CLOSED',
|
||||
'CANCELLED',
|
||||
];
|
||||
|
||||
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
// Callers may narrow to any subset of the ops-clearance lifecycle (the
|
||||
// hub's status filter sends an explicit list); anything outside the
|
||||
// whitelist is dropped so this endpoint can't become a general contract
|
||||
// browser. No statuses given → the original under-review queue.
|
||||
const requested = (filter.statuses ?? filter.status ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) =>
|
||||
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
|
||||
);
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -896,6 +922,7 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 50,
|
||||
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy ?? 'createdAt',
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
|
||||
@@ -145,6 +145,19 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract changes requested', msg);
|
||||
}
|
||||
|
||||
/** GL rejected a shipment request filed under the contract. */
|
||||
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
|
||||
const msg =
|
||||
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
|
||||
(note ? ` Reason: ${note}.` : '') +
|
||||
` Please contact us for details.`;
|
||||
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
|
||||
this.inApp(c, 'Shipment request rejected', msg, {
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
data: { contractId: c.id, reference: requestRef },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
|
||||
@@ -80,9 +80,9 @@ export class ContractPricingService {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 500,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
|
||||
@@ -107,7 +107,7 @@ export class ContractsController {
|
||||
|
||||
@Get('booking-requests/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
||||
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
|
||||
bookingRequestQueue() {
|
||||
return this.bookingRequestService.queue();
|
||||
}
|
||||
@@ -799,6 +799,45 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/initiate')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
|
||||
})
|
||||
initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.contractBookingService.initiateUnderContract(
|
||||
id,
|
||||
{ contractRouteId: dto?.contractRouteId },
|
||||
{ id: user?.id ?? user?.sub },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||
})
|
||||
completeBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||
// service checks the actor's contracts:create_booking permission.
|
||||
return this.contractBookingService.completeUnderContract(
|
||||
id,
|
||||
bookingId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/validate-shipment')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
@@ -813,11 +852,12 @@ export class ContractsController {
|
||||
|
||||
@Get(':id/capacity')
|
||||
@ApiOperation({
|
||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||
summary:
|
||||
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
|
||||
})
|
||||
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
return this.contractBookingService.computeCapacity(contract);
|
||||
return this.contractBookingService.capacityView(contract);
|
||||
}
|
||||
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
options: ContractListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
},
|
||||
@@ -128,6 +129,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search across contract reference and customer (company) name.
|
||||
// Applied here (not in applyListFilters) because only this query joins the
|
||||
// `company` alias — the summary-metrics query builder does not.
|
||||
if (options.search) {
|
||||
qb.andWhere(
|
||||
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
|
||||
{ search: `%${options.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const sortField =
|
||||
options.sortBy === 'contractValidUntil'
|
||||
? 'contract.contractValidUntil'
|
||||
|
||||
@@ -579,6 +579,7 @@ export class ContractsService {
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||
@@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto {
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
||||
description:
|
||||
'Per-shipment equipment return override; omitted → the contract default applies.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -71,6 +71,15 @@ export class FilterContractDto {
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Free-text search across contract reference and company name.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
// Must be declared before @Get(":id") so "paged" isn't captured as an id.
|
||||
@Get("paged")
|
||||
@ApiOperation({
|
||||
summary: "Paged admin listing of dropdown settings (server-side search)",
|
||||
})
|
||||
listPaged(@Query() query: ListDropdownSettingsQueryDto) {
|
||||
return this.service.listPaged(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { paginateQuery } from "../../common/utils/pagination.util";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
||||
@@ -44,6 +47,27 @@ export class DropdownSettingsRepository
|
||||
});
|
||||
}
|
||||
|
||||
findPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||
// Soft-deleted rows are excluded automatically by the query builder
|
||||
// (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC).
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("setting")
|
||||
.leftJoinAndSelect("setting.children", "option")
|
||||
.orderBy("setting.label", query.sortOrder ?? "ASC")
|
||||
.addOrderBy("option.order", "ASC");
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
"(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)",
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async replaceOptions(
|
||||
settingId: string,
|
||||
options: Array<Partial<DropdownOption>>,
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
@@ -16,71 +19,6 @@ import {
|
||||
IDropdownSettingsRepository,
|
||||
} from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
const STATIONS_TER_CODE = "stations_ter";
|
||||
|
||||
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
||||
{
|
||||
value: "inside_addis_ababa",
|
||||
label: "Addis Ababa",
|
||||
note: "Inside country",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
value: "inside_adama",
|
||||
label: "Adama",
|
||||
note: "Inside country",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
value: "inside_mojo",
|
||||
label: "Mojo",
|
||||
note: "Inside country",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
value: "inside_awash",
|
||||
label: "Awash",
|
||||
note: "Inside country",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
value: "inside_mieso",
|
||||
label: "Mieso",
|
||||
note: "Inside country",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
value: "inside_dire_dawa",
|
||||
label: "Dire Dawa",
|
||||
note: "Inside country",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
value: "outside_ali_sabieh",
|
||||
label: "Ali Sabieh",
|
||||
note: "Outside country",
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
value: "outside_holhol",
|
||||
label: "Holhol",
|
||||
note: "Outside country",
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
value: "outside_djibouti_city",
|
||||
label: "Djibouti City",
|
||||
note: "Outside country",
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
value: "outside_doraleh_terminal",
|
||||
label: "Doraleh Terminal",
|
||||
note: "Outside country",
|
||||
order: 10,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DropdownSettingsService {
|
||||
constructor(
|
||||
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
listPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<DropdownSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
|
||||
return this.getById(setting.id);
|
||||
}
|
||||
|
||||
async seedDefaultStations(): Promise<void> {
|
||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||
|
||||
if (!existing) {
|
||||
await this.create({
|
||||
code: STATIONS_TER_CODE,
|
||||
label: "Stations TER",
|
||||
description:
|
||||
"Temporary freight station list used by booking origin and destination yards.",
|
||||
multiple: false,
|
||||
meta: {
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
version: "temporary",
|
||||
},
|
||||
children: DEFAULT_STATION_OPTIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((existing.children?.length ?? 0) === 0) {
|
||||
await this.repository.replaceOptions(
|
||||
existing.id,
|
||||
DEFAULT_STATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDropdownSettingDto,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
|
||||
|
||||
/**
|
||||
* Query params for the paged admin listing (`GET /dropdown-settings/paged`).
|
||||
* `search` matches code, label and description server-side. The entity has no
|
||||
* status/isActive flag, so the base pagination fields are all that's needed.
|
||||
*/
|
||||
export class ListDropdownSettingsQueryDto extends PaginationQueryDto {}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto";
|
||||
import { DropdownOption } from "../entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "../entities/dropdown-setting.entity";
|
||||
|
||||
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
|
||||
|
||||
export interface IDropdownSettingsRepository {
|
||||
findAll(): Promise<DropdownSetting[]>;
|
||||
findPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>>;
|
||||
findById(id: string): Promise<DropdownSetting | null>;
|
||||
findByCode(code: string): Promise<DropdownSetting | null>;
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ import { GpsPosition } from './entities/gps-position.entity';
|
||||
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
|
||||
import { GpsTrackingService } from './gps-tracking.service';
|
||||
import { GpsTrackingController } from './gps-tracking.controller';
|
||||
import { Gt06Server } from './gt06/gt06.server';
|
||||
|
||||
// NOTE: the GT06 TCP listener now lives in the standalone @edr/gps-tracker app.
|
||||
// This module is REST-only — it reads gps_devices / gps_positions that the
|
||||
// tracker app writes to the shared DB. Do not re-add Gt06Server here, or two
|
||||
// processes would fight for the tracker socket.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
|
||||
controllers: [GpsTrackingController],
|
||||
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
|
||||
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService],
|
||||
exports: [GpsTrackingService],
|
||||
})
|
||||
export class GpsTrackingModule {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Proof of delivery captured by the EDR driver when a last-mile leg is
|
||||
* completed. Sent as multipart/form-data — the recipient's signature (field
|
||||
* `signature`) and proof photos (field `photos`) are uploaded alongside these
|
||||
* text fields.
|
||||
*/
|
||||
export class RecordProofOfDeliveryDto {
|
||||
@ApiProperty({ description: 'Name of the person who received the cargo.' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(160)
|
||||
recipientName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional delivery notes.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
notes?: string;
|
||||
}
|
||||
@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
|
||||
|
||||
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||
vehicleAssignments?: LastMileVehicleAssignment[];
|
||||
|
||||
// ── Proof of delivery (captured by the EDR driver on completion) ──────────
|
||||
@Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true })
|
||||
podRecipientName?: string | null;
|
||||
|
||||
/** File id of the recipient's captured signature (PNG). */
|
||||
@Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true })
|
||||
podSignatureFileId?: string | null;
|
||||
|
||||
/** File ids of the delivery proof photos. */
|
||||
@Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' })
|
||||
podPhotoFileIds!: string[];
|
||||
|
||||
@Column({ name: 'pod_notes', type: 'text', nullable: true })
|
||||
podNotes?: string | null;
|
||||
|
||||
@Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true })
|
||||
podCapturedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
@@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
@@ -115,6 +119,19 @@ export class LastMileController {
|
||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||
}
|
||||
|
||||
@Post(':id/proof-of-delivery')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
|
||||
async recordProofOfDelivery(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RecordProofOfDeliveryDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
||||
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
|
||||
@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||
@@ -47,6 +49,7 @@ export class LastMileService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly history: FleetHistoryService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
/** Attach real invoice info (number/status) to records so the UI can show an
|
||||
@@ -210,6 +213,49 @@ export class LastMileService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record proof of delivery (recipient signature + photos + notes) and complete
|
||||
* the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
|
||||
* vehicle release, history).
|
||||
*/
|
||||
async recordProofOfDelivery(
|
||||
id: string,
|
||||
dto: RecordProofOfDeliveryDto,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<LastMile> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const signature = files.find((f) => f.fieldname === 'signature');
|
||||
const photos = files.filter((f) => f.fieldname === 'photos');
|
||||
|
||||
const signatureFileId = signature
|
||||
? (
|
||||
await this.filesService.upload({
|
||||
resourceId: id,
|
||||
resource: 'last-mile',
|
||||
code: 'pod-signature',
|
||||
file: signature,
|
||||
})
|
||||
).id
|
||||
: null;
|
||||
const photoFileIds = photos.length
|
||||
? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
|
||||
: [];
|
||||
|
||||
await this.lastMileRepository.update(id, {
|
||||
podRecipientName: dto.recipientName.trim(),
|
||||
podSignatureFileId: signatureFileId,
|
||||
podPhotoFileIds: photoFileIds,
|
||||
podNotes: dto.notes?.trim() || null,
|
||||
podCapturedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
if (existing.status !== 'DELIVERED') {
|
||||
return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
const record = await this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Notification, User, Session]),
|
||||
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
|
||||
CompaniesModule,
|
||||
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
|
||||
// CompaniesModule imports this module back for CompanyNotifierService.
|
||||
forwardRef(() => CompaniesModule),
|
||||
// BackofficeService.getOrganizationEmployees (staff targeting)
|
||||
BackofficeModule,
|
||||
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
||||
|
||||
@@ -43,11 +43,26 @@ export class Route extends BaseEntity {
|
||||
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||
* machine identifier and is only a fallback for a yard missing one.
|
||||
*
|
||||
* When the route's milestones are loaded (with their yards), the label is the FULL
|
||||
* ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already
|
||||
* include the origin (first) and destination (last). Without milestones it falls
|
||||
* back to origin → destination.
|
||||
*/
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; label?: string } | null;
|
||||
destinationYard?: { code?: string; label?: string } | null;
|
||||
milestones?: Array<{
|
||||
sequenceNo: number;
|
||||
yard?: { code?: string; label?: string } | null;
|
||||
}> | null;
|
||||
}): string {
|
||||
const stops = [...(route.milestones ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
if (stops.length >= 2) return stops.join(' → ');
|
||||
|
||||
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
@@ -19,15 +20,8 @@ export class ApprovalRulesController {
|
||||
@Get()
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({ summary: 'List approval rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
requiresDirectorApproval:
|
||||
query['requiresDirectorApproval'] !== undefined
|
||||
? query['requiresDirectorApproval'] === 'true'
|
||||
: undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListApprovalRulesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get('chain')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
@@ -19,19 +20,8 @@ export class CargoTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('cargo-types')
|
||||
@ApiOperation({ summary: 'List cargo types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
|
||||
? query['requiresDirectorApproval'] === 'true'
|
||||
: undefined,
|
||||
parentGroupId: query['parentGroupId'],
|
||||
search: query['search'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
sortBy: query['sortBy'],
|
||||
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
|
||||
});
|
||||
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
@@ -19,12 +20,8 @@ export class ContainerTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('container-types')
|
||||
@ApiOperation({ summary: 'List container types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
@@ -19,13 +20,8 @@ export class PriorityConfigsController {
|
||||
@Get()
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListPriorityConfigsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -22,13 +23,8 @@ export class RatesController {
|
||||
@Get()
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'List rates' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
status: query['status'],
|
||||
rateType: query['rateType'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListRatesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||
@@ -19,16 +20,8 @@ export class ServiceTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('service-types')
|
||||
@ApiOperation({ summary: 'List service types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
|
||||
search: query['search'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
sortBy: query['sortBy'],
|
||||
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
|
||||
});
|
||||
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||
import { ShippingLinesService } from '../services/shipping-lines.service';
|
||||
|
||||
@@ -17,12 +18,8 @@ export class ShippingLinesController {
|
||||
@Get()
|
||||
@RuleEngineView('shipping-lines')
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
|
||||
|
||||
@@ -17,13 +18,8 @@ export class WeightLimitRulesController {
|
||||
@Get()
|
||||
@RuleEngineView('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'List weight limit rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
tradeDirection: query['tradeDirection'],
|
||||
containerTypeId: query['containerTypeId'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListWeightLimitRulesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
@@ -19,13 +20,8 @@ export class YardsController {
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
country: query['country'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListYardsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
/**
|
||||
* Query-string booleans arrive as strings; implicit conversion is disabled
|
||||
* app-wide, so coerce explicitly. Mirrors the previous controller behaviour
|
||||
* (`query['flag'] === 'true'`): only the literal "true" is truthy.
|
||||
*/
|
||||
const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined =>
|
||||
value === undefined || value === null || value === '' ? undefined : value === true || value === 'true';
|
||||
|
||||
/**
|
||||
* Shared list query for rule-engine resources. Every rule-engine list endpoint
|
||||
* returns the standard `PaginatedResponse` envelope (`items` + `meta`) built by
|
||||
* `common/utils/pagination.util.ts`; `search` is applied server-side against
|
||||
* each resource's human-readable columns (see the repository `findPaged`).
|
||||
*/
|
||||
export class ListRuleEngineQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by active flag.' })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListCargoTypesQueryDto extends ListRuleEngineQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by director-approval requirement.' })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
requiresDirectorApproval?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by parent cargo-type group.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['displayOrder', 'cargoTypeName', 'code', 'createdAt'], default: 'displayOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['displayOrder', 'cargoTypeName', 'code', 'createdAt'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListContainerTypesQueryDto extends ListRuleEngineQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['displayOrder'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListPriorityConfigsQueryDto extends ListRuleEngineQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['WAGON', 'CURRENCY', 'CUSTOMS'] })
|
||||
@IsOptional()
|
||||
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
|
||||
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
|
||||
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['displayOrder'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListServiceTypesQueryDto extends ListRuleEngineQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by standalone-bookable flag.' })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
canBeBookedAlone?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['displayOrder', 'serviceName', 'code', 'createdAt'], default: 'displayOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['displayOrder', 'serviceName', 'code', 'createdAt'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListYardsQueryDto extends ListRuleEngineQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by yard country.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
country?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['displayOrder'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalBoolean)
|
||||
@IsBoolean()
|
||||
requiresDirectorApproval?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['stepOrder'], default: 'stepOrder' })
|
||||
@IsOptional()
|
||||
@IsIn(['stepOrder'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by rate status (DRAFT, PENDING_APPROVAL, LIVE...).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by derived rate type.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
rateType?: string;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by container type.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by trade direction (IMPORT/EXPORT/BOTH).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
tradeDirection?: string;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
|
||||
export interface IApprovalRulesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IApprovalRulesRepository {
|
||||
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
|
||||
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
|
||||
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
|
||||
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>>;
|
||||
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
|
||||
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
|
||||
export interface ICargoTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface ICargoTypesRepository {
|
||||
findByCode(code: string): Promise<CargoType | null>;
|
||||
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
|
||||
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
|
||||
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>>;
|
||||
create(data: Partial<CargoType>): Promise<CargoType>;
|
||||
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
|
||||
export interface IContainerTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IContainerTypesRepository {
|
||||
findByCode(code: string): Promise<ContainerType | null>;
|
||||
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
|
||||
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
|
||||
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>>;
|
||||
create(data: Partial<ContainerType>): Promise<ContainerType>;
|
||||
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
|
||||
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
||||
@@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository {
|
||||
findById(id: string): Promise<PriorityConfig | null>;
|
||||
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
||||
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>>;
|
||||
findAllActive(): Promise<PriorityConfig[]>;
|
||||
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
||||
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
|
||||
export interface IRatesRepository {
|
||||
@@ -13,6 +15,7 @@ export interface IRatesRepository {
|
||||
}): Promise<Rate | null>;
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>>;
|
||||
create(data: Partial<Rate>): Promise<Rate>;
|
||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ServiceType } from '../entities/service-type.entity';
|
||||
|
||||
export interface IServiceTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IServiceTypesRepository {
|
||||
findByCode(code: string): Promise<ServiceType | null>;
|
||||
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
|
||||
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
|
||||
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>>;
|
||||
create(data: Partial<ServiceType>): Promise<ServiceType>;
|
||||
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
|
||||
export interface IShippingLinesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IShippingLinesRepository {
|
||||
findByCode(code: string): Promise<ShippingLine | null>;
|
||||
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
|
||||
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
|
||||
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>>;
|
||||
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
|
||||
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
|
||||
export interface IWeightLimitRulesRepository {
|
||||
@@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository {
|
||||
): Promise<WeightLimitRule | null>;
|
||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
||||
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
||||
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>>;
|
||||
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
||||
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
|
||||
export interface IYardsRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IYardsRepository {
|
||||
findByCode(code: string): Promise<Yard | null>;
|
||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
||||
create(data: Partial<Yard>): Promise<Yard>;
|
||||
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
|
||||
|
||||
@@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged list in the standard envelope. Chain grouping is preserved: rows are
|
||||
* grouped by chain (requiresDirectorApproval) first, then step order.
|
||||
*/
|
||||
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rule')
|
||||
.orderBy('rule.requiresDirectorApproval', 'ASC')
|
||||
.addOrderBy('rule.stepOrder', query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.requiresDirectorApproval !== undefined) {
|
||||
qb.andWhere('rule.requiresDirectorApproval = :requiresDirectorApproval', {
|
||||
requiresDirectorApproval: query.requiresDirectorApproval,
|
||||
});
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rule.actionLabel ILIKE :search OR rule.requiredRole ILIKE :search OR rule.blocksRole ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (name/code) in the standard envelope. */
|
||||
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('cargoType')
|
||||
.leftJoinAndSelect('cargoType.parent', 'parent')
|
||||
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('cargoType.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.requiresDirectorApproval !== undefined) {
|
||||
qb.andWhere('cargoType.requiresDirectorApproval = :requiresDirectorApproval', {
|
||||
requiresDirectorApproval: query.requiresDirectorApproval,
|
||||
});
|
||||
}
|
||||
if (query.parentGroupId !== undefined) {
|
||||
qb.andWhere('cargoType.parentGroupId = :parentGroupId', { parentGroupId: query.parentGroupId });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere('(cargoType.cargoTypeName ILIKE :search OR cargoType.code ILIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<CargoType>): Promise<CargoType> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (label/code) in the standard envelope. */
|
||||
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('containerType')
|
||||
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('containerType.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere('(containerType.label ILIKE :search OR containerType.code ILIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<ContainerType>): Promise<ContainerType> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
||||
|
||||
@@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (label/type/currency) in the standard envelope. */
|
||||
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('config')
|
||||
.orderBy(`config.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.type !== undefined) {
|
||||
qb.andWhere('config.type = :type', { type: query.type });
|
||||
}
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('config.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(config.label ILIKE :search OR config.type ILIKE :search OR config.currency ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async findAllActive(): Promise<PriorityConfig[]> {
|
||||
return this.repo.find({
|
||||
where: { isActive: true },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { IRatesRepository } from '../interfaces/rates.repository.interface';
|
||||
|
||||
@@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (type/status/unit/currency), newest first. */
|
||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('rate.status = :status', { status: query.status });
|
||||
}
|
||||
if (query.rateType) {
|
||||
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<Rate>): Promise<Rate> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ServiceType } from '../entities/service-type.entity';
|
||||
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (name/code/description) in the standard envelope. */
|
||||
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('serviceType')
|
||||
.orderBy(`serviceType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('serviceType.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.canBeBookedAlone !== undefined) {
|
||||
qb.andWhere('serviceType.canBeBookedAlone = :canBeBookedAlone', {
|
||||
canBeBookedAlone: query.canBeBookedAlone,
|
||||
});
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(serviceType.serviceName ILIKE :search OR serviceType.code ILIKE :search OR serviceType.description ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<ServiceType>): Promise<ServiceType> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
|
||||
|
||||
@@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (label/code/mappedToCode), ordered by code. */
|
||||
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('line')
|
||||
.orderBy('line.code', query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('line.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(line.label ILIKE :search OR line.code ILIKE :search OR line.mappedToCode ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
|
||||
|
||||
@@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged list with the container relation loaded, newest first. `search`
|
||||
* matches the trade direction and the joined container type's label/code.
|
||||
*/
|
||||
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rule')
|
||||
.leftJoinAndSelect('rule.containerType', 'containerType')
|
||||
.orderBy('rule.createdAt', query.sortOrder ?? 'DESC');
|
||||
|
||||
if (query.containerTypeId) {
|
||||
qb.andWhere('rule.containerTypeId = :containerTypeId', {
|
||||
containerTypeId: query.containerTypeId,
|
||||
});
|
||||
}
|
||||
if (query.tradeDirection) {
|
||||
qb.andWhere('rule.tradeDirection = :tradeDirection', { tradeDirection: query.tradeDirection });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rule.tradeDirection ILIKE :search OR containerType.label ILIKE :search OR containerType.code ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
import { IYardsRepository } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
/** Paged list with server-side search (label/code/country) in the standard envelope. */
|
||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('yard')
|
||||
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
|
||||
.addOrderBy('yard.label', 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('yard.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.country) {
|
||||
qb.andWhere('yard.country = :country', { country: query.country });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(yard.label ILIKE :search OR yard.code ILIKE :search OR yard.country ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<Yard>): Promise<Yard> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
@@ -17,26 +19,9 @@ export class ApprovalRulesService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List approval rules. */
|
||||
async findAll(filter: {
|
||||
requiresDirectorApproval?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 10;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.requiresDirectorApproval !== undefined) {
|
||||
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
/** List approval rules — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** Get approval chain for a cargo type flag. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user