mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -24,11 +24,3 @@ coverage/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
# emacs cache files
|
|
||||||
*~
|
|
||||||
\#*\#
|
|
||||||
.\#*
|
|
||||||
branch_structure.json
|
|
||||||
temp_auto_push.bat
|
|
||||||
temp_interactive_push.bat
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
|
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -44,13 +45,13 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
|
"libphonenumber-js": "^1.13.6",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"puppeteer": "^24.2.0",
|
"puppeteer": "^24.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.30"
|
"typeorm": "^0.3.30"
|
||||||
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
|
|||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
|
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
|
||||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
@@ -94,6 +95,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
|||||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||||
}),
|
}),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
|
BookingOrdersModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Country-aware phone validation. The value is expected as a full international
|
||||||
|
* number (E.164, e.g. "+251911223344"), so the country is derived from the
|
||||||
|
* value itself — no separate country field needed.
|
||||||
|
*/
|
||||||
|
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
|
||||||
|
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||||
|
if (value === undefined || value === null || value === '') return true;
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
return isValidPhoneNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Class-validator decorator wrapping the country-aware phone constraint. */
|
||||||
|
export function IsValidPhone(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsValidPhoneConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a phone string to canonical E.164. Returns the canonical form when
|
||||||
|
* parseable, otherwise the trimmed original (tolerant — never throws), or the
|
||||||
|
* value unchanged when empty/nullish.
|
||||||
|
*/
|
||||||
|
export function normalizeE164(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return value;
|
||||||
|
const parsed = parsePhoneNumberFromString(value);
|
||||||
|
return parsed?.isValid() ? parsed.number : value.trim();
|
||||||
|
}
|
||||||
@@ -14,17 +14,13 @@ export default registerAs("app", () => ({
|
|||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
},
|
},
|
||||||
|
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||||
cbeExchange: {
|
cbeExchange: {
|
||||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
||||||
scrapeUrl:
|
scrapeUrl:
|
||||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||||
process.env.CBE_EXCHANGE_API_URL ??
|
process.env.CBE_EXCHANGE_API_URL ??
|
||||||
"https://ethio.forex/bank/CBET",
|
"https://ethio.forex/bank/CBET",
|
||||||
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
|
|
||||||
apiUrl:
|
|
||||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
|
||||||
process.env.CBE_EXCHANGE_API_URL ??
|
|
||||||
"https://ethio.forex/bank/CBET",
|
|
||||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Existing users already use the portal — never re-gate them behind the
|
||||||
|
// new onboarding wizard.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.external_profiles
|
||||||
|
SET onboarding_completed = true
|
||||||
|
WHERE onboarding_completed = false;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill the active mode for existing users from their company's
|
||||||
|
// operational profiles. Prefer importer, then exporter, then whichever
|
||||||
|
// single profile the company has (forwarder/dj/transporter).
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.external_profiles ep
|
||||||
|
SET active_profile_type = cp.type
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (company_id) company_id, type
|
||||||
|
FROM freight.company_profiles
|
||||||
|
ORDER BY company_id,
|
||||||
|
CASE type
|
||||||
|
WHEN 'importer' THEN 0
|
||||||
|
WHEN 'exporter' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
) cp
|
||||||
|
WHERE ep.company_id = cp.company_id
|
||||||
|
AND ep.active_profile_type IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS onboarding_completed;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS onboarding_step;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS active_profile_type;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddCompanyProfileIdToBookings1791000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddCompanyProfileIdToBookings1791000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
|
||||||
|
ON freight.bookings(company_profile_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD CONSTRAINT "FK_bookings_company_profile_id"
|
||||||
|
FOREIGN KEY (company_profile_id)
|
||||||
|
REFERENCES freight.company_profiles(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
|
||||||
|
// profile, for each booking's own company.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET company_profile_id = cp.id
|
||||||
|
FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = b.company_id
|
||||||
|
AND b.company_profile_id IS NULL
|
||||||
|
AND (
|
||||||
|
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
|
||||||
|
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Forwarder / single-profile companies: one profile per company, so the
|
||||||
|
// mapping is unambiguous regardless of trade direction.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET company_profile_id = cp.id
|
||||||
|
FROM freight.company_profiles cp
|
||||||
|
JOIN freight.companies c ON c.id = cp.company_id
|
||||||
|
WHERE cp.company_id = b.company_id
|
||||||
|
AND c.type <> 'customer'
|
||||||
|
AND b.company_profile_id IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
|
||||||
|
// matching profile): attribute to the company's importer profile, else its
|
||||||
|
// exporter profile, so nothing disappears from the customer's list.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET company_profile_id = cp.id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (company_id) company_id, id
|
||||||
|
FROM freight.company_profiles
|
||||||
|
ORDER BY company_id,
|
||||||
|
CASE type
|
||||||
|
WHEN 'importer' THEN 0
|
||||||
|
WHEN 'exporter' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
) cp
|
||||||
|
WHERE cp.company_id = b.company_id
|
||||||
|
AND b.company_id IS NOT NULL
|
||||||
|
AND b.company_profile_id IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS company_profile_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddNationalityToCompanies1791000000002
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddNationalityToCompanies1791000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS nationality varchar(32);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Existing companies default to Ethiopian (country defaults to Ethiopia).
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.companies
|
||||||
|
SET nationality = 'ethiopian'
|
||||||
|
WHERE nationality IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS nationality;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.company_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.company_profiles
|
||||||
|
DROP COLUMN IF EXISTS business_license_files;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddETradeFieldsToCompanies1791000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddETradeFieldsToCompanies1791000000003";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS licence_number varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS status_description text;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS date_registered varchar(50);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS renewed_from varchar(50);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS renewal_date varchar(50);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS renewed_to varchar(50);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS region varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS zone varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS woreda varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS kebele varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS house_no varchar(100);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS etrade_phone varchar(20);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS licence_number;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS status_description;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS date_registered;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS renewed_from;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS renewal_date;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS renewed_to;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS region;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS zone;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS woreda;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS kebele;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS house_no;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS etrade_phone;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the generic dropdown settings tables (freight.dropdown_settings +
|
||||||
|
* freight.dropdown_options) backing the DropdownSetting / DropdownOption
|
||||||
|
* entities. These tables previously only existed via `synchronize` on some
|
||||||
|
* databases; this migration makes them part of the migration history so the
|
||||||
|
* SeedGeneralContractPeriod migration (which inserts into them) can run on a
|
||||||
|
* fresh database. Idempotent so it is safe on DBs where the tables already exist.
|
||||||
|
*/
|
||||||
|
export class CreateDropdownSettings1791999999999
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreateDropdownSettings1791999999999';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."dropdown_settings" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"code" varchar(128) NOT NULL,
|
||||||
|
"label" varchar(256) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"multiple" boolean NOT NULL DEFAULT false,
|
||||||
|
"meta" jsonb,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
CONSTRAINT "PK_dropdown_settings" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_settings_code"
|
||||||
|
ON "freight"."dropdown_settings" ("code");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."dropdown_options" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"setting_id" uuid NOT NULL,
|
||||||
|
"value" varchar(256) NOT NULL,
|
||||||
|
"label" varchar(256) NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"is_disabled" boolean NOT NULL DEFAULT false,
|
||||||
|
"display_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"meta" jsonb,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
CONSTRAINT "PK_dropdown_options" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_dropdown_options_setting" FOREIGN KEY ("setting_id")
|
||||||
|
REFERENCES "freight"."dropdown_settings" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_options_setting_value"
|
||||||
|
ON "freight"."dropdown_options" ("setting_id", "value");
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS "freight"."dropdown_options";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS "freight"."dropdown_settings";`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUnitOfMeasureToCargoTypes1792000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUnitOfMeasureToCargoTypes1792000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBookingTypeAndContractFields1792000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddBookingTypeAndContractFields1792000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`,
|
||||||
|
);
|
||||||
|
// General contracts have no shipment date at creation — relax the NOT NULL.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`,
|
||||||
|
);
|
||||||
|
// Reinstate NOT NULL only if no null rows exist (general contracts would block it).
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateBookingOrders1792000000002 implements MigrationInterface {
|
||||||
|
name = 'CreateBookingOrders1792000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'booking_orders',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'reference', type: 'varchar', length: '64', isUnique: true },
|
||||||
|
{ name: 'contract_booking_id', type: 'uuid' },
|
||||||
|
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'company_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'scheduled_date', type: 'timestamptz' },
|
||||||
|
{ name: 'status', type: 'varchar', length: '40', default: "'PAID'" },
|
||||||
|
{ name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" },
|
||||||
|
{ name: 'train_schedule_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_orders',
|
||||||
|
new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_orders',
|
||||||
|
new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'booking_order_lines',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'order_id', type: 'uuid' },
|
||||||
|
{ name: 'container_type_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3 },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['order_id'],
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedTableName: 'booking_orders',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_order_lines',
|
||||||
|
new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.booking_order_lines', true);
|
||||||
|
await queryRunner.dropTable('freight.booking_orders', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the global "general contract period" setting (months). Stored as a
|
||||||
|
* dropdown_settings row with a single option whose `value` holds the month count
|
||||||
|
* so backoffice can manage it through the existing settings UI later.
|
||||||
|
*/
|
||||||
|
export class SeedGeneralContractPeriod1792000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'SeedGeneralContractPeriod1792000000003';
|
||||||
|
private readonly code = 'general_contract_period';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const existing = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const inserted = await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||||
|
VALUES ($1, $2, $3, false)
|
||||||
|
RETURNING id;`,
|
||||||
|
[
|
||||||
|
this.code,
|
||||||
|
'General Contract Period (months)',
|
||||||
|
'How many months a general contract stays open for ordering after activation.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const settingId = inserted[0].id;
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||||
|
VALUES ($1, $2, $3, 0);`,
|
||||||
|
[settingId, '3', '3 months'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { BookingOrdersService } from './booking-orders.service';
|
||||||
|
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||||
|
import { GeneralContractService } from './general-contract.service';
|
||||||
|
|
||||||
|
@ApiTags('Booking Orders')
|
||||||
|
@Controller('booking-orders')
|
||||||
|
export class BookingOrdersController {
|
||||||
|
constructor(
|
||||||
|
private readonly ordersService: BookingOrdersService,
|
||||||
|
private readonly generalContractService: GeneralContractService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
|
||||||
|
async create(
|
||||||
|
@Body() dto: CreateBookingOrderDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.ordersService.create(dto, user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List orders placed against a contract' })
|
||||||
|
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
|
||||||
|
return this.ordersService.listByContract(contractBookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('contract/:id/pool')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Contracted / ordered / remaining quantities for a general contract',
|
||||||
|
})
|
||||||
|
async pool(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.generalContractService.getQuantityLines(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a single booking order' })
|
||||||
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.ordersService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
import { BookingOrdersController } from './booking-orders.controller';
|
||||||
|
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||||
|
import { BookingOrdersService } from './booking-orders.service';
|
||||||
|
import { BookingOrder } from './entities/booking-order.entity';
|
||||||
|
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||||
|
import { GeneralContractService } from './general-contract.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
|
||||||
|
BookingsModule,
|
||||||
|
CompaniesModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
|
forwardRef(() => TrainSchedulingModule),
|
||||||
|
],
|
||||||
|
controllers: [BookingOrdersController],
|
||||||
|
providers: [
|
||||||
|
BookingOrdersService,
|
||||||
|
BookingOrdersRepository,
|
||||||
|
GeneralContractService,
|
||||||
|
],
|
||||||
|
exports: [BookingOrdersService, GeneralContractService],
|
||||||
|
})
|
||||||
|
export class BookingOrdersModule {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BookingOrder } from './entities/booking-order.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BookingOrder)
|
||||||
|
repository: Repository<BookingOrder>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders placed against a given contract, newest first, with their lines. */
|
||||||
|
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { contractBookingId },
|
||||||
|
relations: { lines: { containerType: true }, booking: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override findById(id: string): Promise<BookingOrder | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count this calendar year's orders, for reference generation. */
|
||||||
|
async countByYear(year: number): Promise<number> {
|
||||||
|
const start = new Date(Date.UTC(year, 0, 1));
|
||||||
|
const end = new Date(Date.UTC(year + 1, 0, 1));
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
|
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||||
|
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||||
|
import { BookingOrder } from './entities/booking-order.entity';
|
||||||
|
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||||
|
import { GeneralContractService } from './general-contract.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingOrdersService {
|
||||||
|
private readonly logger = new Logger(BookingOrdersService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly ordersRepository: BookingOrdersRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
|
private readonly generalContractService: GeneralContractService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Orders placed against a contract, with their lines and child booking. */
|
||||||
|
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||||
|
return this.ordersRepository.findByContract(contractBookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<BookingOrder | null> {
|
||||||
|
return this.ordersRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a drawdown order against an ACTIVE general contract.
|
||||||
|
*
|
||||||
|
* Validates the requested quantities against the remaining pool, then spawns a
|
||||||
|
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
|
||||||
|
* route/cargo/service) so it flows through the existing train-scheduling
|
||||||
|
* pipeline. The order row is the ledger entry linking contract → child booking.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateBookingOrderDto,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<BookingOrder> {
|
||||||
|
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
|
||||||
|
}
|
||||||
|
if (!this.generalContractService.isGeneralContract(contract)) {
|
||||||
|
throw new BadRequestException('Booking is not a general contract');
|
||||||
|
}
|
||||||
|
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
|
||||||
|
throw new BadRequestException('Contract ordering window has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The customer placing the order must own the contract.
|
||||||
|
if (userId && !(await this.userOwnsContract(userId, contract))) {
|
||||||
|
throw new BadRequestException('You do not have access to this contract');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the route has a departure on the chosen day.
|
||||||
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
|
const hasDeparture =
|
||||||
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
|
contract.originYardId,
|
||||||
|
contract.destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
if (!hasDeparture) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No departures available on the selected day for this route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each line against the remaining pool.
|
||||||
|
const poolLines = await this.generalContractService.getQuantityLines(
|
||||||
|
contract.id,
|
||||||
|
);
|
||||||
|
const isContainer = contract.freightType === 'CONTAINER';
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
if (line.quantity <= 0) {
|
||||||
|
throw new BadRequestException('Order quantities must be greater than zero');
|
||||||
|
}
|
||||||
|
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||||
|
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||||
|
if (!poolLine) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
isContainer
|
||||||
|
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||||
|
: 'This contract has no matching quantity pool',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (line.quantity > poolLine.remainingQuantity) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||||
|
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the order + its child shipment booking atomically.
|
||||||
|
const order = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const childBooking = await this.spawnChildBooking(contract, dto, manager);
|
||||||
|
|
||||||
|
const reference = await this.generateReference();
|
||||||
|
const orderRow = manager.create(BookingOrder, {
|
||||||
|
reference,
|
||||||
|
contractBookingId: contract.id,
|
||||||
|
bookingId: childBooking.id,
|
||||||
|
companyId: contract.companyId ?? null,
|
||||||
|
scheduledDate: new Date(dto.scheduledDate),
|
||||||
|
status: 'PAID',
|
||||||
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
|
});
|
||||||
|
const savedOrder = await manager.save(orderRow);
|
||||||
|
|
||||||
|
const lines = dto.lines.map((l) =>
|
||||||
|
manager.create(BookingOrderLine, {
|
||||||
|
orderId: savedOrder.id,
|
||||||
|
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||||
|
quantity: l.quantity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.save(lines);
|
||||||
|
savedOrder.lines = lines;
|
||||||
|
return savedOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feed the child booking into the day-pool batch so it allocates to a train.
|
||||||
|
try {
|
||||||
|
await this.bookingBatchService.processRouteDay({
|
||||||
|
originYardId: contract.originYardId,
|
||||||
|
destinationYardId: contract.destinationYardId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the contract once its pool is exhausted.
|
||||||
|
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(contract.id, { status: 'CONTRACT_CLOSED' });
|
||||||
|
this.logger.log(
|
||||||
|
`Contract ${contract.reference} CLOSED — quantity exhausted`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await this.ordersRepository.findById(order.id)) ?? order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
||||||
|
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
|
||||||
|
*/
|
||||||
|
private async spawnChildBooking(
|
||||||
|
contract: Booking,
|
||||||
|
dto: CreateBookingOrderDto,
|
||||||
|
manager: import('typeorm').EntityManager,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const reference = await this.generateChildBookingReference();
|
||||||
|
const now = new Date();
|
||||||
|
const isContainer = contract.freightType === 'CONTAINER';
|
||||||
|
|
||||||
|
// Sum line quantities × the contract's per-unit weight for the child total.
|
||||||
|
const containerByType = new Map(
|
||||||
|
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
|
||||||
|
);
|
||||||
|
let totalWeight = 0;
|
||||||
|
if (isContainer) {
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
const src = containerByType.get(line.containerTypeId ?? '');
|
||||||
|
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||||
|
totalWeight += vgmPerUnit * line.quantity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = manager.create(Booking, {
|
||||||
|
reference,
|
||||||
|
companyId: contract.companyId ?? null,
|
||||||
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
|
isGovernment: contract.isGovernment,
|
||||||
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
|
contractType: contract.contractType,
|
||||||
|
previousContractId: contract.id,
|
||||||
|
serviceTypeId: contract.serviceTypeId,
|
||||||
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
|
equipmentReturn: contract.equipmentReturn,
|
||||||
|
originYardId: contract.originYardId,
|
||||||
|
destinationYardId: contract.destinationYardId,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType: contract.freightType,
|
||||||
|
cargoTypeId: contract.cargoTypeId ?? null,
|
||||||
|
cargoFreeText: contract.cargoFreeText ?? null,
|
||||||
|
shippingLineId: contract.shippingLineId ?? null,
|
||||||
|
cargoTotalWeightVgm: totalWeight,
|
||||||
|
isHazardous: contract.isHazardous,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
bookingType: 'ONE_TIME',
|
||||||
|
scheduledDate: new Date(dto.scheduledDate),
|
||||||
|
// Already covered by the contract's one-time payment: enter the pool ready
|
||||||
|
// and paid so the batch engine reserves → allocates it immediately.
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
fullyExecutedAt: now,
|
||||||
|
customerSignedAt: now,
|
||||||
|
priorityScore: contract.priorityScore,
|
||||||
|
totalAmount: 0,
|
||||||
|
allowConsolidation: false,
|
||||||
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
|
});
|
||||||
|
const savedChild = await manager.save(child);
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
const src = containerByType.get(line.containerTypeId ?? '');
|
||||||
|
const ct = line.containerTypeId
|
||||||
|
? await manager.getRepository(ContainerType).findOne({
|
||||||
|
where: { id: line.containerTypeId },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||||
|
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||||
|
const row = manager.create(BookingContainer, {
|
||||||
|
bookingId: savedChild.id,
|
||||||
|
containerTypeId: line.containerTypeId ?? null,
|
||||||
|
quantity: line.quantity,
|
||||||
|
vgmPerUnitTons: vgmPerUnit,
|
||||||
|
totalVgmTons: vgmPerUnit * line.quantity,
|
||||||
|
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
|
||||||
|
isOverweight: false,
|
||||||
|
});
|
||||||
|
await manager.save(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async userOwnsContract(
|
||||||
|
userId: string,
|
||||||
|
contract: Booking,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!contract.companyId) return true; // government / staff-created
|
||||||
|
try {
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return company.id === contract.companyId;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateReference(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await this.ordersRepository.countByYear(year);
|
||||||
|
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateChildBookingReference(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await this.bookingsRepository.countByYear(year);
|
||||||
|
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
|
|
||||||
|
/** A single contracted/ordered/remaining pool line for a general contract. */
|
||||||
|
export class ContractQuantityLineView {
|
||||||
|
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||||
|
containerTypeId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
containerTypeName!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
|
||||||
|
unitOfMeasure!: CargoUnitOfMeasure | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
contractedQuantity!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
orderedQuantity!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
remainingQuantity!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBookingOrderLineDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
containerTypeId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
quantity!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBookingOrderDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
||||||
|
@IsUUID()
|
||||||
|
contractBookingId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CreateBookingOrderLineDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateBookingOrderLineDto)
|
||||||
|
lines!: CreateBookingOrderLineDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||||
|
import { BookingOrder } from './booking-order.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||||
|
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||||
|
* single line with a null containerTypeId carries the tons/items.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_order_lines' })
|
||||||
|
export class BookingOrderLine extends BaseEntity {
|
||||||
|
@Column({ name: 'order_id', type: 'uuid' })
|
||||||
|
orderId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'order_id' })
|
||||||
|
order?: BookingOrder;
|
||||||
|
|
||||||
|
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||||
|
containerTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => ContainerType, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'container_type_id' })
|
||||||
|
containerType?: ContainerType | null;
|
||||||
|
|
||||||
|
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||||
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||||
|
quantity!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { BookingOrderLine } from './booking-order-line.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single drawdown against a general contract. Each order spawns its own
|
||||||
|
* ONE_TIME child Booking (the shipment that enters the train scheduling
|
||||||
|
* pipeline); this row is the ledger entry linking the contract to that
|
||||||
|
* shipment and recording the drawn-down quantities.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_orders' })
|
||||||
|
export class BookingOrder extends BaseEntity {
|
||||||
|
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
||||||
|
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
||||||
|
contractBookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking)
|
||||||
|
@JoinColumn({ name: 'contract_booking_id' })
|
||||||
|
contractBooking?: Booking;
|
||||||
|
|
||||||
|
/** The ONE_TIME child shipment booking spawned for this order. */
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||||
|
bookingId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking | null;
|
||||||
|
|
||||||
|
/** Denormalized from the contract for fast company-scoped filtering. */
|
||||||
|
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||||
|
companyId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company?: Company | null;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||||
|
scheduledDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'scheduling_status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 30,
|
||||||
|
default: SchedulingStatus.NotScheduled,
|
||||||
|
})
|
||||||
|
schedulingStatus!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
|
||||||
|
lines?: BookingOrderLine[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingOrder } from './entities/booking-order.entity';
|
||||||
|
import { ContractQuantityLineView } from './dto/contract-view.dto';
|
||||||
|
|
||||||
|
/** Setting code holding the global ordering window (in months) for general contracts. */
|
||||||
|
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
||||||
|
/** Fallback when the setting is missing or unparseable. */
|
||||||
|
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns general-contract concerns that sit alongside the generic booking flow:
|
||||||
|
* the configurable ordering period, post-payment activation, and computing the
|
||||||
|
* remaining drawdown pool per contract.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GeneralContractService {
|
||||||
|
private readonly logger = new Logger(GeneralContractService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly dropdownSettings: DropdownSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
||||||
|
return booking.bookingType === BookingType.GeneralContract;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configured ordering window in months (defaults to 3). */
|
||||||
|
async getPeriodMonths(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const setting = await this.dropdownSettings.getByCode(
|
||||||
|
CONTRACT_PERIOD_SETTING_CODE,
|
||||||
|
);
|
||||||
|
const raw = setting.children?.[0]?.value;
|
||||||
|
const months = Number(raw);
|
||||||
|
if (Number.isFinite(months) && months > 0) return months;
|
||||||
|
} catch {
|
||||||
|
// Setting not seeded yet — fall back to the default.
|
||||||
|
}
|
||||||
|
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
||||||
|
* entering the train queue like a one-time booking) and stamp the ordering
|
||||||
|
* window. Idempotent.
|
||||||
|
*/
|
||||||
|
async activateAfterPayment(bookingId: string): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(Booking);
|
||||||
|
const booking = await repo.findOne({ where: { id: bookingId } });
|
||||||
|
if (!booking || !this.isGeneralContract(booking)) return;
|
||||||
|
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = await this.getPeriodMonths();
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setMonth(expiresAt.getMonth() + months);
|
||||||
|
|
||||||
|
await repo.update(bookingId, {
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
||||||
|
* per container type for CONTAINER contracts, or a single total line for
|
||||||
|
* BULK/BREAK_BULK (keyed on a null container type).
|
||||||
|
*/
|
||||||
|
async getQuantityLines(
|
||||||
|
contractBookingId: string,
|
||||||
|
): Promise<ContractQuantityLineView[]> {
|
||||||
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: contractBookingId },
|
||||||
|
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||||
|
});
|
||||||
|
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
||||||
|
|
||||||
|
const ordered = await this.orderedByContainerType(contractBookingId);
|
||||||
|
|
||||||
|
if (booking.freightType === 'CONTAINER') {
|
||||||
|
return (booking.bookingContainers ?? []).map((c) => {
|
||||||
|
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
||||||
|
const contracted = Number(c.quantity);
|
||||||
|
return {
|
||||||
|
containerTypeId: c.containerTypeId ?? null,
|
||||||
|
containerTypeName: c.containerType?.label ?? null,
|
||||||
|
unitOfMeasure: null,
|
||||||
|
contractedQuantity: contracted,
|
||||||
|
orderedQuantity: orderedQty,
|
||||||
|
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
||||||
|
const orderedQty = ordered.get('') ?? 0;
|
||||||
|
const contracted = Number(booking.cargoTotalWeightVgm);
|
||||||
|
const uom: CargoUnitOfMeasure | null =
|
||||||
|
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
||||||
|
CargoUnitOfMeasure.PerTon;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
containerTypeId: null,
|
||||||
|
containerTypeName: null,
|
||||||
|
unitOfMeasure: uom,
|
||||||
|
contractedQuantity: contracted,
|
||||||
|
orderedQuantity: orderedQty,
|
||||||
|
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||||
|
private async orderedByContainerType(
|
||||||
|
contractBookingId: string,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const rows = await this.dataSource
|
||||||
|
.getRepository(BookingOrder)
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.innerJoin('o.lines', 'line')
|
||||||
|
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
||||||
|
.addSelect('SUM(line.quantity)', 'total')
|
||||||
|
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||||
|
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||||
|
.setParameter('empty', '')
|
||||||
|
.groupBy('key')
|
||||||
|
.getRawMany<{ key: string; total: string }>();
|
||||||
|
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
||||||
|
async remainingFor(
|
||||||
|
contractBookingId: string,
|
||||||
|
containerTypeKey: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const lines = await this.getQuantityLines(contractBookingId);
|
||||||
|
const line = lines.find(
|
||||||
|
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
||||||
|
);
|
||||||
|
return line?.remainingQuantity ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once every contracted line is fully drawn down. */
|
||||||
|
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||||
|
const lines = await this.getQuantityLines(contractBookingId);
|
||||||
|
return lines.every((l) => l.remainingQuantity <= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
let service: BookingPricingService;
|
let service: BookingPricingService;
|
||||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||||
let ratesService: { findLiveRates: jest.Mock };
|
let ratesService: { findLiveRates: jest.Mock };
|
||||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
let exchangeService: { getRate: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||||
ratesService = {
|
ratesService = {
|
||||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||||
};
|
};
|
||||||
cbeExchangeService = {
|
exchangeService = {
|
||||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new BookingPricingService(
|
service = new BookingPricingService(
|
||||||
@@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
ratesService as never,
|
ratesService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
cbeExchangeService as never,
|
exchangeService as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
|||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import {
|
import {
|
||||||
AppliedCargoModifier,
|
AppliedCargoModifier,
|
||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
@@ -41,7 +41,7 @@ export class BookingPricingService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly serviceTypesService: ServiceTypesService,
|
private readonly serviceTypesService: ServiceTypesService,
|
||||||
private readonly cbeExchangeService: CbeExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -84,7 +84,7 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|
||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -285,7 +285,7 @@ export class BookingPricingService {
|
|||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
const isBulk = booking.freightType === 'BULK';
|
const isBulk = booking.freightType === 'BULK';
|
||||||
|
|
||||||
const rateType =
|
const rateType =
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
|
|||||||
name: child.cargoTypeName,
|
name: child.cargoTypeName,
|
||||||
code: child.code,
|
code: child.code,
|
||||||
show_free_text_box: child.showFreeTextBox,
|
show_free_text_box: child.showFreeTextBox,
|
||||||
|
unit_of_measure: child.unitOfMeasure ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -132,8 +132,39 @@ export class BookingsController {
|
|||||||
const companyId =
|
const companyId =
|
||||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||||
if (!companyId) return { items: [], total: 0 };
|
if (!companyId) {
|
||||||
return this.bookingsService.findAll(filter, companyId);
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Scope to the active operational profile (importer/exporter) when one
|
||||||
|
// resolves; otherwise fall back to company-level scoping.
|
||||||
|
const companyProfileId =
|
||||||
|
await this.bookingsService.resolveActiveCompanyProfileId(userId);
|
||||||
|
return this.bookingsService.findAll(
|
||||||
|
filter,
|
||||||
|
companyId,
|
||||||
|
companyProfileId ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-company/:companyId/customer-view')
|
||||||
|
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
|
||||||
|
findByCompanyCustomerView(
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
) {
|
||||||
|
return this.bookingsService.findCustomerBookings(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('list-summary')
|
@Get('list-summary')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
|
|
||||||
// import { CustomersModule } from '../customers/customers.module';
|
// import { CustomersModule } from '../customers/customers.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
@@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
|||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { PaymentModule } from '../payment/payment.module';
|
import { PaymentModule } from '../payment/payment.module';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
|||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
|
ExchangeModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController],
|
controllers: [BookingsController, PayController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
|||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
CbeExchangeService,
|
|
||||||
],
|
],
|
||||||
exports: [BookingsService, BookingsRepository],
|
exports: [BookingsService, BookingsRepository],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,14 +25,18 @@ export interface BookingListFilterOptions {
|
|||||||
schedulingStatuses?: string[];
|
schedulingStatuses?: string[];
|
||||||
assignedToSchedule?: 'true' | 'false';
|
assignedToSchedule?: 'true' | 'false';
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
companyProfileId?: string;
|
||||||
contractType?: string;
|
contractType?: string;
|
||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
cargoTypeId?: string;
|
cargoTypeId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
|
bookingType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
excludePaymentStatus?: string;
|
excludePaymentStatus?: string;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
}
|
}
|
||||||
@@ -434,7 +438,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{ items: Booking[]; total: number }> {
|
}): Promise<{
|
||||||
|
items: Booking[];
|
||||||
|
total: number;
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
const page = options.page;
|
const page = options.page;
|
||||||
const pageSize = options.pageSize;
|
const pageSize = options.pageSize;
|
||||||
|
|
||||||
@@ -481,7 +496,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, total };
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
|
// Return both the flat `total` (consumed by the backoffice list) and a
|
||||||
|
// `meta` block (consumed by the portal, matching PaginationMeta) so neither
|
||||||
|
// app needs to change its read shape.
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
@@ -559,6 +589,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
companyId: options.companyId,
|
companyId: options.companyId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.companyProfileId) {
|
||||||
|
qb.andWhere('booking.company_profile_id = :companyProfileId', {
|
||||||
|
companyProfileId: options.companyProfileId,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.contractType) {
|
if (options.contractType) {
|
||||||
qb.andWhere('booking.contract_type = :contractType', {
|
qb.andWhere('booking.contract_type = :contractType', {
|
||||||
contractType: options.contractType,
|
contractType: options.contractType,
|
||||||
@@ -579,6 +614,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.bookingType) {
|
||||||
|
qb.andWhere('booking.booking_type = :bookingType', {
|
||||||
|
bookingType: options.bookingType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.createdFrom) {
|
||||||
|
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||||
|
createdFrom: options.createdFrom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.createdTo) {
|
||||||
|
// Inclusive end-of-day: callers pass a date; include the whole day.
|
||||||
|
qb.andWhere('booking.created_at <= :createdTo', {
|
||||||
|
createdTo: options.createdTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.tradeDirection) {
|
if (options.tradeDirection) {
|
||||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Freight, SchedulingStatus } from '@edr/types';
|
import { Freight, SchedulingStatus } from '@edr/types';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -41,6 +42,20 @@ import {
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
|
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
|
export interface PaginatedBookings {
|
||||||
|
items: Booking[];
|
||||||
|
total: number;
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
@@ -257,6 +272,7 @@ export class BookingsService {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
const isGovernment = dto.isGovernment === true;
|
const isGovernment = dto.isGovernment === true;
|
||||||
|
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||||
|
|
||||||
let companyId: string | null | undefined = dto.companyId;
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
if (isGovernment) {
|
if (isGovernment) {
|
||||||
@@ -291,11 +307,12 @@ export class BookingsService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!isGeneralContract) {
|
||||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||||
// train later.
|
// train later. General contracts skip this — they have no shipment date at
|
||||||
const day = eatDay(new Date(dto.scheduledDate));
|
// creation; each drawdown order validates its own day.
|
||||||
|
const day = eatDay(new Date(dto.scheduledDate!));
|
||||||
const hasDeparture =
|
const hasDeparture =
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
dto.originYardId,
|
dto.originYardId,
|
||||||
@@ -323,6 +340,29 @@ export class BookingsService {
|
|||||||
dto.tradeDirection,
|
dto.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stamp the operational profile this booking belongs to (importer/exporter)
|
||||||
|
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||||
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
|
let companyProfileId: string | null = null;
|
||||||
|
if (!isGovernment && companyId) {
|
||||||
|
let fallbackType: ProfileType | null = null;
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
const { profile } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
fallbackType = profile.activeProfileType ?? null;
|
||||||
|
} catch {
|
||||||
|
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
companyProfileId =
|
||||||
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
|
companyId,
|
||||||
|
tradeDirection,
|
||||||
|
fallbackType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allowConsolidation =
|
const allowConsolidation =
|
||||||
dto.freightType === 'CONTAINER'
|
dto.freightType === 'CONTAINER'
|
||||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||||
@@ -348,6 +388,7 @@ export class BookingsService {
|
|||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
|
companyProfileId,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
@@ -370,7 +411,8 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
pnrCode: dto.pnrCode,
|
pnrCode: dto.pnrCode,
|
||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||||
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -504,6 +546,22 @@ export class BookingsService {
|
|||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
};
|
};
|
||||||
|
// If the route (hence trade direction) changed, re-stamp the operational
|
||||||
|
// profile so an edited draft doesn't get stranded under the wrong profile.
|
||||||
|
if (
|
||||||
|
tradeDirection !== existing.tradeDirection &&
|
||||||
|
!existing.isGovernment &&
|
||||||
|
existing.companyId
|
||||||
|
) {
|
||||||
|
updates.companyProfileId =
|
||||||
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
|
existing.companyId,
|
||||||
|
tradeDirection,
|
||||||
|
existing.companyProfileId
|
||||||
|
? undefined
|
||||||
|
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||||
@@ -583,7 +641,8 @@ export class BookingsService {
|
|||||||
async findAll(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
): Promise<{ items: Booking[]; total: number }> {
|
forceCompanyProfileId?: string,
|
||||||
|
): Promise<PaginatedBookings> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
const statusFilter = this.parseStatusFilter(filter);
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
@@ -597,14 +656,20 @@ export class BookingsService {
|
|||||||
assignedToSchedule: filter.assignedToSchedule,
|
assignedToSchedule: filter.assignedToSchedule,
|
||||||
// A forced company scope (portal/customer) overrides any caller-provided
|
// A forced company scope (portal/customer) overrides any caller-provided
|
||||||
// companyId so a customer can only ever see their own company's bookings.
|
// companyId so a customer can only ever see their own company's bookings.
|
||||||
companyId: forceCompanyId ?? filter.companyId,
|
// When an active profile resolves, scope to it; otherwise fall back to the
|
||||||
|
// company so nothing breaks for not-yet-onboarded customers.
|
||||||
|
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
|
||||||
|
companyProfileId: forceCompanyProfileId,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
|
bookingType: filter.bookingType,
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
|
createdFrom: filter.createdFrom,
|
||||||
|
createdTo: filter.createdTo,
|
||||||
allowConsolidation: filter.allowConsolidation,
|
allowConsolidation: filter.allowConsolidation,
|
||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
@@ -627,15 +692,20 @@ export class BookingsService {
|
|||||||
async findMyPayable(
|
async findMyPayable(
|
||||||
userId: string,
|
userId: string,
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
): Promise<{ items: Booking[]; total: number }> {
|
): Promise<PaginatedBookings> {
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
// Scope to the active operational profile when one resolves; fall back to
|
||||||
|
// company-level so not-yet-onboarded customers still see their payables.
|
||||||
|
const companyProfileId =
|
||||||
|
await this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||||
|
|
||||||
return this.bookingsRepository.findAllPaginated({
|
return this.bookingsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 20,
|
pageSize: filter.pageSize ?? 20,
|
||||||
statuses: BookingsService.PAYABLE_STATUSES,
|
statuses: BookingsService.PAYABLE_STATUSES,
|
||||||
excludePaymentStatus: 'PAID',
|
excludePaymentStatus: 'PAID',
|
||||||
companyId: company.id,
|
companyId: companyProfileId ? undefined : company.id,
|
||||||
|
companyProfileId: companyProfileId ?? undefined,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -655,6 +725,15 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active company_profile id a customer's bookings should be
|
||||||
|
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
||||||
|
* back to company-level scoping.
|
||||||
|
*/
|
||||||
|
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||||
|
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||||
@@ -756,9 +835,12 @@ export class BookingsService {
|
|||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
|
bookingType: filter.bookingType,
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
|
createdFrom: filter.createdFrom,
|
||||||
|
createdTo: filter.createdTo,
|
||||||
allowConsolidation: filter.allowConsolidation,
|
allowConsolidation: filter.allowConsolidation,
|
||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
};
|
};
|
||||||
@@ -1036,4 +1118,37 @@ export class BookingsService {
|
|||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findCustomerBookings(companyId: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
freightType: string;
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
scheduledDate: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}[]> {
|
||||||
|
const { items } = await this.bookingsRepository.findAllPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
return items.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
reference: b.reference,
|
||||||
|
status: b.status,
|
||||||
|
tradeDirection: b.tradeDirection,
|
||||||
|
freightType: b.freightType,
|
||||||
|
originLabel: b.originYard?.label ?? '',
|
||||||
|
destinationLabel: b.destinationYard?.label ?? '',
|
||||||
|
totalAmount: Number(b.totalAmount),
|
||||||
|
currency: b.paymentCurrency,
|
||||||
|
scheduledDate: b.scheduledDate ?? null,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
|
|
||||||
export class BookingReferenceYardDto {
|
export class BookingReferenceYardDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
show_free_text_box!: boolean;
|
show_free_text_box!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||||
|
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingReferenceCargoTypeGroupDto {
|
export class BookingReferenceCargoTypeGroupDto {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
|
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||||
|
|
||||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||||
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
BOOKING_STATUSES,
|
BOOKING_STATUSES,
|
||||||
|
BOOKING_TYPES,
|
||||||
CONTRACT_TYPES,
|
CONTRACT_TYPES,
|
||||||
EQUIPMENT_RETURNS,
|
EQUIPMENT_RETURNS,
|
||||||
FREIGHT_TYPES,
|
FREIGHT_TYPES,
|
||||||
@@ -105,10 +106,24 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
|
|
||||||
/** The day the customer wants to ship (the pool day key). */
|
@ApiPropertyOptional({
|
||||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
enum: BOOKING_TYPES,
|
||||||
|
default: 'ONE_TIME',
|
||||||
|
description:
|
||||||
|
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...BOOKING_TYPES])
|
||||||
|
bookingType?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day the customer wants to ship (the pool day key). Required for one-time
|
||||||
|
* bookings; omitted for general contracts, which pick the date per order.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
|
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||||
@IsIn([...CONTRACT_TYPES])
|
@IsIn([...CONTRACT_TYPES])
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
import {
|
import {
|
||||||
BOOKING_STATUSES,
|
BOOKING_STATUSES,
|
||||||
|
BOOKING_TYPES,
|
||||||
FREIGHT_TYPES,
|
FREIGHT_TYPES,
|
||||||
PAYMENT_CURRENCIES,
|
PAYMENT_CURRENCIES,
|
||||||
TRADE_DIRECTIONS,
|
TRADE_DIRECTIONS,
|
||||||
@@ -56,6 +57,21 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...FREIGHT_TYPES])
|
@IsIn([...FREIGHT_TYPES])
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...BOOKING_TYPES])
|
||||||
|
bookingType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter bookings created on/after this date (ISO)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter bookings created on/before this date (ISO)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdTo?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types';
|
|||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
// import { Customer } from '../../customers/entities/customer.entity';
|
// import { Customer } from '../../customers/entities/customer.entity';
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||||
@@ -40,10 +41,15 @@ export const BOOKING_STATUSES = [
|
|||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'PENDING_CONSOLIDATION',
|
'PENDING_CONSOLIDATION',
|
||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
|
'CONTRACT_ACTIVE',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|
||||||
|
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
|
||||||
|
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
|
||||||
|
|
||||||
export const PAYMENT_STATUSES = [
|
export const PAYMENT_STATUSES = [
|
||||||
'PENDING',
|
'PENDING',
|
||||||
'PNR_GENERATED',
|
'PNR_GENERATED',
|
||||||
@@ -92,6 +98,20 @@ export class Booking extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
company?: Company | null;
|
company?: Company | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||||
|
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||||
|
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||||
|
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||||
|
* legacy/government/staff-created bookings.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||||
|
companyProfileId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'company_profile_id' })
|
||||||
|
companyProfile?: CompanyProfile | null;
|
||||||
|
|
||||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||||
isGovernment!: boolean;
|
isGovernment!: boolean;
|
||||||
|
|
||||||
@@ -110,8 +130,28 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
|
||||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
/**
|
||||||
scheduledDate!: Date;
|
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
||||||
|
* umbrella contract that is signed/paid once and then drawn down by many
|
||||||
|
* orders (each order spawns its own ONE_TIME child booking).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||||
|
bookingType!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nullable: general contracts have no shipment date at creation — the date is
|
||||||
|
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||||
|
scheduledDate?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General contracts only: when the ordering window closes, computed from the
|
||||||
|
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||||
|
* bookings and for contracts that are not yet active.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
totalAmount!: number;
|
totalAmount!: number;
|
||||||
|
|||||||
@@ -24,15 +24,25 @@ import { UpdateCompanyDto } from "./dto/update-company.dto";
|
|||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||||
|
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||||
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import {
|
import {
|
||||||
ResponseCompanyDto,
|
ResponseCompanyDto,
|
||||||
ResponseCompanyProfileDto,
|
ResponseCompanyProfileDto,
|
||||||
} from "./dto/response-company.dto";
|
} from "./dto/response-company.dto";
|
||||||
|
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
||||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||||
|
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||||
|
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
|
|
||||||
interface CurrentIamUser {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,6 +90,15 @@ export class CompaniesController {
|
|||||||
return this.companiesService.getDashboardSummary(user.id);
|
return this.companiesService.getDashboardSummary(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("fetch-etrade-info")
|
||||||
|
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||||
|
async fetchETradeInfo(
|
||||||
|
@Body() dto: FetchETradeDto,
|
||||||
|
): Promise<ETradeResponseDto> {
|
||||||
|
const data = await this.companiesService.fetchETradeData(dto.tin);
|
||||||
|
return new ETradeResponseDto(data);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("profile")
|
@Patch("profile")
|
||||||
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@@ -105,6 +124,113 @@ export class CompaniesController {
|
|||||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("onboarding/start")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
|
||||||
|
})
|
||||||
|
async startOnboarding(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: StartOnboardingDto,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const nameParts = (user.name?.en ?? "").split(" ");
|
||||||
|
const { profile, company } = await this.companiesService.startOnboarding(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
firstName: nameParts[0] || "",
|
||||||
|
lastName: nameParts.slice(-1)[0] || "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
phone: user.phoneNumber ?? "",
|
||||||
|
},
|
||||||
|
dto.companyType,
|
||||||
|
dto.roles,
|
||||||
|
dto.nationality,
|
||||||
|
);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("company-profile")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create a single operational profile for the current user's company and make it the active mode",
|
||||||
|
})
|
||||||
|
async createCompanyProfile(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: CreateCompanyProfileDto,
|
||||||
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
const profile = await this.companiesService.createCompanyProfileForUser(
|
||||||
|
user.id,
|
||||||
|
dto.type,
|
||||||
|
dto.businessLicense,
|
||||||
|
);
|
||||||
|
return new ResponseCompanyProfileDto(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("company-profiles/:profileId/license")
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Upload business-license document(s) for one of the current user's company profiles",
|
||||||
|
})
|
||||||
|
async uploadProfileLicense(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
|
): Promise<BusinessLicenseFile[]> {
|
||||||
|
return this.companiesService.uploadProfileLicenseFiles(
|
||||||
|
user.id,
|
||||||
|
profileId,
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("company-profiles/:profileId/license")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "List business-license documents for a company profile",
|
||||||
|
})
|
||||||
|
async listProfileLicense(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
): Promise<BusinessLicenseFile[]> {
|
||||||
|
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("active-mode")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||||
|
})
|
||||||
|
async setActiveMode(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: SetActiveModeDto,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } = await this.companiesService.setActiveMode(
|
||||||
|
user.id,
|
||||||
|
dto.type,
|
||||||
|
);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("onboarding-step")
|
||||||
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async setOnboardingStep(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: SetOnboardingStepDto,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("onboarding/complete")
|
||||||
|
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||||
|
async completeOnboarding(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.markOnboardingComplete(user.id);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
// Used by portal
|
// Used by portal
|
||||||
@Post("create")
|
@Post("create")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -142,29 +268,19 @@ export class CompaniesController {
|
|||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||||
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
|
return this.companiesService.getCompanyStats();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all companies" })
|
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
async findAll(
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
@Query() query: ListCompaniesQueryDto,
|
||||||
return companies.map((c) => new ResponseCompanyDto(c));
|
): Promise<{ items: ResponseCompanyDto[]; total: number }> {
|
||||||
}
|
const { items, total } = await this.companiesService.listCompanies(query);
|
||||||
|
return { items: items.map((c) => new ResponseCompanyDto(c)), total };
|
||||||
@Get("type/:type")
|
|
||||||
@ApiOperation({ summary: "Find companies by type" })
|
|
||||||
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
|
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
|
||||||
return companies
|
|
||||||
.filter((c) => c.type === type)
|
|
||||||
.map((c) => new ResponseCompanyDto(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("search")
|
|
||||||
@ApiOperation({ summary: "Search companies by name" })
|
|
||||||
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
|
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
|
||||||
return companies
|
|
||||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
|
||||||
.map((c) => new ResponseCompanyDto(c));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@@ -195,6 +311,23 @@ export class CompaniesController {
|
|||||||
await this.companiesService.deleteCompany(id);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":companyId/documents")
|
||||||
|
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||||
|
async listDocuments(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
) {
|
||||||
|
const files = await this.filesService.findByResource(companyId, "companies");
|
||||||
|
return files.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
code: f.code,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
size: f.size,
|
||||||
|
uploadedAt: f.createdAt,
|
||||||
|
url: f.url,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/documents")
|
@Post(":companyId/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@@ -206,6 +339,20 @@ export class CompaniesController {
|
|||||||
return this.filesService.uploadMany(companyId, "companies", files);
|
return this.filesService.uploadMany(companyId, "companies", files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("company-profiles/:profileId/status")
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||||
|
async updateCompanyProfileStatus(
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||||
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||||
|
profileId,
|
||||||
|
dto.status,
|
||||||
|
);
|
||||||
|
return new ResponseCompanyProfileDto(profile);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
|
import { MinioModule } from "../minio/minio.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
@@ -11,11 +13,14 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
|||||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
|
HttpModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
MinioModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -24,6 +29,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
|
|||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
CompanyProfileRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
|
ETradeService,
|
||||||
],
|
],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Company } from './entities/company.entity';
|
import { Company } from './entities/company.entity';
|
||||||
|
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
|
||||||
|
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompaniesRepository extends BaseRepository<Company> {
|
export class CompaniesRepository extends BaseRepository<Company> {
|
||||||
@@ -32,4 +34,68 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
const count = await this.repository.count({ where: { tin } as any });
|
const count = await this.repository.count({ where: { tin } as any });
|
||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findPaginated(
|
||||||
|
query: ListCompaniesQueryDto,
|
||||||
|
): Promise<{ items: Company[]; total: number }> {
|
||||||
|
const { page = 1, pageSize = 20, search, type, status } = query;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
|
||||||
|
.where('company.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
qb.andWhere('company.type = :type', { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
qb.andWhere('company.status = :status', { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const term = `%${search.trim()}%`;
|
||||||
|
qb.andWhere(
|
||||||
|
`(company.name ILIKE :term
|
||||||
|
OR company.tin ILIKE :term
|
||||||
|
OR company.email ILIKE :term
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = company.id
|
||||||
|
AND cp.reference ILIKE :term
|
||||||
|
AND cp.deleted_at IS NULL
|
||||||
|
))`,
|
||||||
|
{ term },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await qb
|
||||||
|
.orderBy('company.name', 'ASC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
|
const rows: { status: string; count: string }[] = await this.repository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.select('company.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('company.deleted_at IS NULL')
|
||||||
|
.groupBy('company.status')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
|
||||||
|
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
active: map.get('active') ?? 0,
|
||||||
|
pending: map.get('pending') ?? 0,
|
||||||
|
suspended: map.get('suspended') ?? 0,
|
||||||
|
blacklisted: map.get('blacklisted') ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { CompaniesRepository } from "./companies.repository";
|
|||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
|
import { MinioService } from "../minio/minio.service";
|
||||||
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
@@ -15,9 +18,17 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
|
|||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
import { Company } from "./entities/company.entity";
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||||
|
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||||
|
import {
|
||||||
|
Company,
|
||||||
|
CompanyNationality,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
} from "./entities/company.entity";
|
||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
|
BusinessLicenseFile,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
ProfileType,
|
ProfileType,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
@@ -38,6 +49,8 @@ export class CompaniesService {
|
|||||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
private readonly etradeService: ETradeService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
@@ -76,20 +89,34 @@ export class CompaniesService {
|
|||||||
fanNumber: dto.fanNumber ?? null,
|
fanNumber: dto.fanNumber ?? null,
|
||||||
country: dto.companyLocation ?? "Ethiopia",
|
country: dto.companyLocation ?? "Ethiopia",
|
||||||
address: dto.companyAddress ?? null,
|
address: dto.companyAddress ?? null,
|
||||||
phone: dto.companyPhone ?? null,
|
phone: normalizeE164(dto.companyPhone) ?? null,
|
||||||
email: dto.companyEmail ?? null,
|
email: dto.companyEmail ?? null,
|
||||||
attributes: dto.attributes ?? null,
|
attributes: dto.attributes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default active mode from the chosen role(s): importer wins when both are
|
||||||
|
// picked, otherwise the first allowed type chosen.
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
const chosenTypes = (dto.companyProfiles ?? [])
|
||||||
|
.map((p) => p.type)
|
||||||
|
.filter((t) => allowedTypes.includes(t));
|
||||||
|
const activeProfileType =
|
||||||
|
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||||
|
chosenTypes[0] ??
|
||||||
|
allowedTypes[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
const profile = await this.profilesRepo.create({
|
const profile = await this.profilesRepo.create({
|
||||||
userId: identity.userId,
|
userId: identity.userId,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
email: identity.email,
|
email: identity.email,
|
||||||
phone: identity.phone,
|
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
|
activeProfileType,
|
||||||
|
onboardingStep: "company",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist the operational role(s) chosen during onboarding. Types are
|
// Persist the operational role(s) chosen during onboarding. Types are
|
||||||
@@ -123,6 +150,131 @@ export class CompaniesService {
|
|||||||
return { company, profile };
|
return { company, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCompanies(
|
||||||
|
query: ListCompaniesQueryDto,
|
||||||
|
): Promise<{ items: Company[]; total: number }> {
|
||||||
|
return this.companiesRepo.findPaginated(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCompanyStats(): Promise<CompanyStatsResponseDto> {
|
||||||
|
return this.companiesRepo.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin onboarding: create a DRAFT company + the user's external profile + the
|
||||||
|
* chosen operational role(s) up front, so every subsequent wizard step can
|
||||||
|
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||||
|
*
|
||||||
|
* Idempotent: if the user already has a profile, returns it unchanged (only
|
||||||
|
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
||||||
|
* (the real one is filled on the Company Information step) and stays
|
||||||
|
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||||
|
*/
|
||||||
|
async startOnboarding(
|
||||||
|
identity: UserIdentity,
|
||||||
|
companyType: CompanyType,
|
||||||
|
roles: ProfileType[],
|
||||||
|
nationality?: CompanyNationality,
|
||||||
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
// Already started — reuse the existing draft, just ensure roles exist and
|
||||||
|
// keep the nationality up to date if it was (re)selected.
|
||||||
|
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||||
|
if (existing) {
|
||||||
|
const companyId = existing.company?.id ?? existing.companyId;
|
||||||
|
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||||
|
if (nationality) {
|
||||||
|
await this.companiesRepo.update(companyId, { nationality });
|
||||||
|
}
|
||||||
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile may exist for the same email under a different IAM id — block
|
||||||
|
// duplicates as the final create does.
|
||||||
|
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
||||||
|
if (byEmail) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Profile with email ${identity.email} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
|
const activeProfileType =
|
||||||
|
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||||
|
chosenTypes[0] ??
|
||||||
|
allowedTypes[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const company = await this.companiesRepo.create({
|
||||||
|
name: identity.firstName
|
||||||
|
? `${identity.firstName}'s company`
|
||||||
|
: "New company",
|
||||||
|
type: companyType,
|
||||||
|
tin: await this.generateDraftTin(),
|
||||||
|
country: "Ethiopia",
|
||||||
|
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.profilesRepo.create({
|
||||||
|
userId: identity.userId,
|
||||||
|
companyId: company.id,
|
||||||
|
firstName: identity.firstName,
|
||||||
|
lastName: identity.lastName,
|
||||||
|
email: identity.email,
|
||||||
|
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||||
|
isPrimaryContact: true,
|
||||||
|
activeProfileType,
|
||||||
|
onboardingStep: "company",
|
||||||
|
onboardingCompleted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||||
|
|
||||||
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create any of the requested operational profiles that don't exist yet. */
|
||||||
|
private async ensureCompanyProfiles(
|
||||||
|
companyId: string,
|
||||||
|
companyType: CompanyType,
|
||||||
|
roles: ProfileType[],
|
||||||
|
): Promise<void> {
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
|
for (const type of roles) {
|
||||||
|
if (!allowedTypes.includes(type)) continue;
|
||||||
|
const existing = await this.companyProfilesRepo.findByType(
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
if (existing) continue;
|
||||||
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||||
|
await this.companyProfilesRepo.create({
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
reference,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique 10-char placeholder TIN for a draft company (the column is
|
||||||
|
* NOT NULL + unique). Overwritten with the real TIN on the company step.
|
||||||
|
*/
|
||||||
|
private async generateDraftTin(): Promise<string> {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const candidate =
|
||||||
|
"D" +
|
||||||
|
Math.floor(Math.random() * 1_000_000_000)
|
||||||
|
.toString()
|
||||||
|
.padStart(9, "0");
|
||||||
|
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
|
||||||
|
}
|
||||||
|
// Extremely unlikely; fall back to a timestamp-derived value.
|
||||||
|
return ("D" + Date.now().toString()).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
async findAllCompanies(): Promise<Company[]> {
|
async findAllCompanies(): Promise<Company[]> {
|
||||||
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||||
}
|
}
|
||||||
@@ -146,8 +298,9 @@ export class CompaniesService {
|
|||||||
`Company for profile ${profile.id} not found`,
|
`Company for profile ${profile.id} not found`,
|
||||||
);
|
);
|
||||||
|
|
||||||
company.companyProfiles =
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||||
await this.companyProfilesRepo.findByCompanyId(company.id);
|
company.id,
|
||||||
|
);
|
||||||
|
|
||||||
return { profile, company };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
@@ -174,6 +327,18 @@ export class CompaniesService {
|
|||||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||||
if (!companyId) return this.emptyDashboardSummary();
|
if (!companyId) return this.emptyDashboardSummary();
|
||||||
|
|
||||||
|
// Scope KPIs to the active operational profile (importer/exporter mode) when
|
||||||
|
// one resolves; otherwise aggregate across the whole company.
|
||||||
|
const companyProfileId = profile?.activeProfileType
|
||||||
|
? ((await this.companyProfilesRepo.findByType(
|
||||||
|
companyId,
|
||||||
|
profile.activeProfileType,
|
||||||
|
)) ?? null)
|
||||||
|
: null;
|
||||||
|
const scope = companyProfileId
|
||||||
|
? { companyProfileId: companyProfileId.id }
|
||||||
|
: { companyId };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||||
@@ -191,22 +356,22 @@ export class CompaniesService {
|
|||||||
tonnagePrev,
|
tonnagePrev,
|
||||||
monthlyRows,
|
monthlyRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
this.dashboardRepo.countDelivered(scope, yearStart, now),
|
||||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
this.dashboardRepo.countCommitted(scope, yearStart, now),
|
||||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
|
||||||
this.dashboardRepo.sumPaidSpendByCurrency(
|
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||||
companyId,
|
scope,
|
||||||
prevYearStart,
|
prevYearStart,
|
||||||
prevYearToDate,
|
prevYearToDate,
|
||||||
),
|
),
|
||||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
|
||||||
this.dashboardRepo.sumCommittedTonnage(
|
this.dashboardRepo.sumCommittedTonnage(
|
||||||
companyId,
|
scope,
|
||||||
prevYearStart,
|
prevYearStart,
|
||||||
prevYearToDate,
|
prevYearToDate,
|
||||||
),
|
),
|
||||||
this.dashboardRepo.monthlyCommittedTonnage(
|
this.dashboardRepo.monthlyCommittedTonnage(
|
||||||
companyId,
|
scope,
|
||||||
this.monthsAgo(now, 5),
|
this.monthsAgo(now, 5),
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
@@ -323,14 +488,27 @@ export class CompaniesService {
|
|||||||
const companyUpdates: Record<string, any> = {};
|
const companyUpdates: Record<string, any> = {};
|
||||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||||
|
|
||||||
|
if (dto.nationality !== undefined)
|
||||||
|
companyUpdates.nationality = dto.nationality;
|
||||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
if (dto.companyPhone !== undefined)
|
||||||
|
companyUpdates.phone = normalizeE164(dto.companyPhone);
|
||||||
if (dto.companyLocation !== undefined)
|
if (dto.companyLocation !== undefined)
|
||||||
companyUpdates.country = dto.companyLocation;
|
companyUpdates.country = dto.companyLocation;
|
||||||
if (dto.companyAddress !== undefined)
|
if (dto.companyAddress !== undefined)
|
||||||
companyUpdates.address = dto.companyAddress;
|
companyUpdates.address = dto.companyAddress;
|
||||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
||||||
|
// Reject a TIN already taken by a different company (the user's own draft
|
||||||
|
// placeholder is fine to overwrite).
|
||||||
|
const owner = await this.companiesRepo.findByTin(dto.tin);
|
||||||
|
if (owner && owner.id !== company.id) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
companyUpdates.tin = dto.tin;
|
||||||
|
}
|
||||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
if (dto.fanNumber !== undefined) {
|
if (dto.fanNumber !== undefined) {
|
||||||
companyUpdates.fanNumber = dto.fanNumber;
|
companyUpdates.fanNumber = dto.fanNumber;
|
||||||
@@ -338,21 +516,45 @@ export class CompaniesService {
|
|||||||
|
|
||||||
if (dto.contactPersonName !== undefined)
|
if (dto.contactPersonName !== undefined)
|
||||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
|
if (dto.contactPersonPosition !== undefined)
|
||||||
|
attrUpdates.contactPersonPosition = dto.contactPersonPosition;
|
||||||
|
if (dto.contactPersonEmail !== undefined)
|
||||||
|
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||||
if (dto.contactPersonPhone !== undefined)
|
if (dto.contactPersonPhone !== undefined)
|
||||||
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||||
if (dto.generalManagerName !== undefined)
|
if (dto.generalManagerName !== undefined)
|
||||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
if (dto.generalManagerEmail !== undefined)
|
if (dto.generalManagerEmail !== undefined)
|
||||||
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||||
if (dto.generalManagerPhone !== undefined)
|
if (dto.generalManagerPhone !== undefined)
|
||||||
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
|
||||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
if (dto.poaPhone !== undefined)
|
||||||
|
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||||
if (dto.poaLocation !== undefined)
|
if (dto.poaLocation !== undefined)
|
||||||
attrUpdates.poaLocation = dto.poaLocation;
|
attrUpdates.poaLocation = dto.poaLocation;
|
||||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
|
if (dto.licenceNumber !== undefined)
|
||||||
|
companyUpdates.licenceNumber = dto.licenceNumber;
|
||||||
|
if (dto.statusDescription !== undefined)
|
||||||
|
companyUpdates.statusDescription = dto.statusDescription;
|
||||||
|
if (dto.dateRegistered !== undefined)
|
||||||
|
companyUpdates.dateRegistered = dto.dateRegistered;
|
||||||
|
if (dto.renewedFrom !== undefined)
|
||||||
|
companyUpdates.renewedFrom = dto.renewedFrom;
|
||||||
|
if (dto.renewalDate !== undefined)
|
||||||
|
companyUpdates.renewalDate = dto.renewalDate;
|
||||||
|
if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo;
|
||||||
|
if (dto.region !== undefined) companyUpdates.region = dto.region;
|
||||||
|
if (dto.zone !== undefined) companyUpdates.zone = dto.zone;
|
||||||
|
if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda;
|
||||||
|
if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele;
|
||||||
|
if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo;
|
||||||
|
if (dto.etradePhone !== undefined)
|
||||||
|
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||||
|
|
||||||
companyUpdates.attributes = attrUpdates;
|
companyUpdates.attributes = attrUpdates;
|
||||||
|
|
||||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
@@ -393,7 +595,13 @@ export class CompaniesService {
|
|||||||
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||||
switch (companyType) {
|
switch (companyType) {
|
||||||
case "customer":
|
case "customer":
|
||||||
return [ProfileType.importer, ProfileType.exporter];
|
// A customer can operate as an importer and/or exporter, and may also
|
||||||
|
// add a freight-forwarder service profile under the same company.
|
||||||
|
return [
|
||||||
|
ProfileType.importer,
|
||||||
|
ProfileType.exporter,
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
];
|
||||||
case "freight_forwarder":
|
case "freight_forwarder":
|
||||||
return [ProfileType.freightForwarder];
|
return [ProfileType.freightForwarder];
|
||||||
case "dj_freight_forwarder":
|
case "dj_freight_forwarder":
|
||||||
@@ -405,6 +613,19 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setCompanyProfileStatus(
|
||||||
|
profileId: string,
|
||||||
|
status: ProfileStatus,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const updated = await this.companyProfilesRepo.updateStatus(
|
||||||
|
profileId,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async createCompanyProfile(
|
async createCompanyProfile(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
profileType?: ProfileType,
|
profileType?: ProfileType,
|
||||||
@@ -505,4 +726,243 @@ export class CompaniesService {
|
|||||||
|
|
||||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async createCompanyProfileForUser(
|
||||||
|
userId: string,
|
||||||
|
type: ProfileType,
|
||||||
|
businessLicense?: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
if (!allowedTypes.includes(type)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
|
if (!created) {
|
||||||
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||||
|
created = await this.companyProfilesRepo.create({
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
reference,
|
||||||
|
businessLicense: businessLicense ?? null,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the user's active operational mode. The target profile must already
|
||||||
|
* exist — clients create it first via createCompanyProfileForUser.
|
||||||
|
*/
|
||||||
|
async setActiveMode(
|
||||||
|
userId: string,
|
||||||
|
type: ProfileType,
|
||||||
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
if (!allowedTypes.includes(type)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
|
if (!existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`No ${type} profile exists yet — create it before switching`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||||
|
|
||||||
|
return this.getCompanyInfoByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOnboardingComplete(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
|
||||||
|
// Guard against finishing on a still-draft company (TIN never filled in).
|
||||||
|
if (!company.tin || company.tin.startsWith("D")) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Company information is incomplete — please fill in your company details before finishing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every operational profile must have at least one business-license file
|
||||||
|
// (stored directly on the profile).
|
||||||
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
|
for (const cp of profiles) {
|
||||||
|
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.profilesRepo.update(profile.id, {
|
||||||
|
onboardingCompleted: true,
|
||||||
|
onboardingStep: "done",
|
||||||
|
});
|
||||||
|
await this.companiesRepo.update(companyId, {
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
return this.getCompanyInfoByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize and resolve a company_profile that must belong to the current
|
||||||
|
* user's company — used before accepting/returning its license files.
|
||||||
|
*/
|
||||||
|
async resolveOwnedProfile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const owned = (company.companyProfiles ?? []).find(
|
||||||
|
(p) => p.id === profileId,
|
||||||
|
);
|
||||||
|
if (!owned) {
|
||||||
|
throw new NotFoundException(`Profile ${profileId} not found`);
|
||||||
|
}
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload business-license document(s) and store them directly on the company
|
||||||
|
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
||||||
|
* persisted on the profile — intentionally not via the FileRecord file model.
|
||||||
|
* New files are appended to any already present. Returns the full list.
|
||||||
|
*/
|
||||||
|
async uploadProfileLicenseFiles(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<BusinessLicenseFile[]> {
|
||||||
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
|
||||||
|
const uploaded: BusinessLicenseFile[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
||||||
|
const url = await this.minioService.uploadFile(
|
||||||
|
objectName,
|
||||||
|
file.buffer,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
uploaded.push({
|
||||||
|
name: file.originalname,
|
||||||
|
url,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
||||||
|
await this.companyProfilesRepo.update(profileId, {
|
||||||
|
businessLicenseFiles: next,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The business-license files stored on a single company profile. */
|
||||||
|
async listProfileLicenseFiles(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<BusinessLicenseFile[]> {
|
||||||
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
return profile.businessLicenseFiles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
|
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||||
|
* when the natural profile doesn't exist) it falls back to the user's active
|
||||||
|
* profile, then the company's first profile. Returns null when the company
|
||||||
|
* has no profiles at all.
|
||||||
|
*/
|
||||||
|
async resolveCompanyProfileIdForBooking(
|
||||||
|
companyId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
fallbackType?: ProfileType | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
|
if (profiles.length === 0) return null;
|
||||||
|
|
||||||
|
const naturalType =
|
||||||
|
tradeDirection === "IMPORT"
|
||||||
|
? ProfileType.importer
|
||||||
|
: tradeDirection === "EXPORT"
|
||||||
|
? ProfileType.exporter
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const byType = (type?: ProfileType | null) =>
|
||||||
|
type ? profiles.find((p) => p.type === type) : undefined;
|
||||||
|
|
||||||
|
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||||
|
return match?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the company_profile a customer's data should be scoped to, from
|
||||||
|
* their persisted active mode. Returns null when nothing can be resolved
|
||||||
|
* (not onboarded yet) so callers can fall back to company-level scoping.
|
||||||
|
*/
|
||||||
|
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const type = profile.activeProfileType;
|
||||||
|
if (!type) return null;
|
||||||
|
const match = company.companyProfiles?.find((p) => p.type === type);
|
||||||
|
return match?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchETradeData(tin: string) {
|
||||||
|
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||||
|
if (!businessInfo) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No business license found for this TIN. Please check the number and try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.etradeService.extractRegistrationData(businessInfo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
@@ -31,6 +31,27 @@ export interface CurrencyTotal {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the dashboard is scoped to: a single operational profile (the active
|
||||||
|
* importer/exporter mode) when one resolves, otherwise the whole company
|
||||||
|
* (legacy / not-yet-onboarded fallback).
|
||||||
|
*/
|
||||||
|
export type DashboardScope =
|
||||||
|
| { companyProfileId: string }
|
||||||
|
| { companyId: string };
|
||||||
|
|
||||||
|
/** Apply the scope as a WHERE clause on a bookings query builder. */
|
||||||
|
function applyScope(
|
||||||
|
qb: SelectQueryBuilder<Booking>,
|
||||||
|
scope: DashboardScope,
|
||||||
|
): SelectQueryBuilder<Booking> {
|
||||||
|
return 'companyProfileId' in scope
|
||||||
|
? qb.where('b.company_profile_id = :companyProfileId', {
|
||||||
|
companyProfileId: scope.companyProfileId,
|
||||||
|
})
|
||||||
|
: qb.where('b.company_id = :companyId', { companyId: scope.companyId });
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonthlyTonnage {
|
export interface MonthlyTonnage {
|
||||||
year: number;
|
year: number;
|
||||||
month: number; // 1-12
|
month: number; // 1-12
|
||||||
@@ -50,35 +71,33 @@ export class CompanyDashboardRepository {
|
|||||||
private readonly bookings: Repository<Booking>,
|
private readonly bookings: Repository<Booking>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
/** Count of delivered/completed bookings within [from, to) for the scope. */
|
||||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||||
return this.bookings
|
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||||
.createQueryBuilder('b')
|
|
||||||
.where('b.company_id = :companyId', { companyId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('b.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
/** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */
|
||||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||||
return this.bookings
|
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||||
.createQueryBuilder('b')
|
|
||||||
.where('b.company_id = :companyId', { companyId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('b.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
/** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */
|
||||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||||
const rows = await this.bookings
|
const rows = await applyScope(
|
||||||
.createQueryBuilder('b')
|
this.bookings
|
||||||
.select('b.payment_currency', 'currency')
|
.createQueryBuilder('b')
|
||||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
.select('b.payment_currency', 'currency')
|
||||||
.where('b.company_id = :companyId', { companyId })
|
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('b.deleted_at IS NULL')
|
||||||
.andWhere("b.payment_status = 'PAID'")
|
.andWhere("b.payment_status = 'PAID'")
|
||||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
@@ -88,12 +107,14 @@ export class CompanyDashboardRepository {
|
|||||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
/** Total committed tonnage (cargo VGM) within [from, to) for the scope. */
|
||||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||||
const row = await this.bookings
|
const row = await applyScope(
|
||||||
.createQueryBuilder('b')
|
this.bookings
|
||||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
.createQueryBuilder('b')
|
||||||
.where('b.company_id = :companyId', { companyId })
|
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('b.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
@@ -102,14 +123,16 @@ export class CompanyDashboardRepository {
|
|||||||
return Number(row?.total ?? 0);
|
return Number(row?.total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
/** Committed tonnage grouped by calendar month within [from, to) for the scope. */
|
||||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||||
const rows = await this.bookings
|
const rows = await applyScope(
|
||||||
.createQueryBuilder('b')
|
this.bookings
|
||||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
.createQueryBuilder('b')
|
||||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||||
.where('b.company_id = :companyId', { companyId })
|
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('b.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
|
import { CompanyProfile, ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||||
|
|
||||||
const SEQUENCE_MAP: Record<ProfileType, string> = {
|
const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||||
[ProfileType.exporter]: "seq_company_profile_ex",
|
[ProfileType.exporter]: "seq_company_profile_ex",
|
||||||
@@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
|||||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||||
[ProfileType.exporter]: "EX",
|
[ProfileType.exporter]: "EX",
|
||||||
[ProfileType.importer]: "IM",
|
[ProfileType.importer]: "IM",
|
||||||
[ProfileType.freightForwarder]: "FFE",
|
[ProfileType.freightForwarder]: "FF",
|
||||||
[ProfileType.djFreightForwarder]: "FWJ",
|
[ProfileType.djFreightForwarder]: "FWJ",
|
||||||
[ProfileType.transporter]: "TR",
|
[ProfileType.transporter]: "TR",
|
||||||
};
|
};
|
||||||
@@ -58,4 +58,16 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
|||||||
async findByReference(reference: string): Promise<CompanyProfile | null> {
|
async findByReference(reference: string): Promise<CompanyProfile | null> {
|
||||||
return this.repository.findOne({ where: { reference } });
|
return this.repository.findOne({ where: { reference } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<CompanyProfile | null> {
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: ProfileStatus,
|
||||||
|
): Promise<CompanyProfile | null> {
|
||||||
|
await this.repository.update({ id }, { status });
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class CompanyInfoResponseDto {
|
|||||||
company: ResponseCompanyDto;
|
company: ResponseCompanyDto;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company: Company) {
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
this.profile = new ResponseExternalProfileDto(profile);
|
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||||
this.company = new ResponseCompanyDto(company);
|
this.company = new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class CompanyStatsResponseDto {
|
||||||
|
total!: number;
|
||||||
|
active!: number;
|
||||||
|
pending!: number;
|
||||||
|
suspended!: number;
|
||||||
|
blacklisted!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
|
||||||
|
export class CreateCompanyProfileDto {
|
||||||
|
@IsEnum(ProfileType)
|
||||||
|
type!: ProfileType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
businessLicense?: string;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { CompanyType } from '../entities/company.entity';
|
import { CompanyType } from '../entities/company.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
export class CompanyProfileInputDto {
|
export class CompanyProfileInputDto {
|
||||||
@IsEnum(ProfileType)
|
@IsEnum(ProfileType)
|
||||||
@@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
|
@IsValidPhone()
|
||||||
companyPhone?: string;
|
companyPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
export class CreateCompanyDto {
|
export class CreateCompanyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -37,6 +38,7 @@ export class CreateCompanyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
|
@IsValidPhone()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||||
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
export class CreateExternalProfileDto {
|
export class CreateExternalProfileDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -26,6 +27,7 @@ export class CreateExternalProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
|
@IsValidPhone()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { CompanyRegistrationData } from "@edr/types";
|
||||||
|
|
||||||
|
export class ETradeResponseDto implements CompanyRegistrationData {
|
||||||
|
licenceNumber!: string;
|
||||||
|
statusDescription!: string;
|
||||||
|
dateRegistered!: string;
|
||||||
|
renewedFrom!: string;
|
||||||
|
renewalDate!: string;
|
||||||
|
renewedTo!: string;
|
||||||
|
region!: string;
|
||||||
|
zone!: string;
|
||||||
|
woreda!: string;
|
||||||
|
kebele!: string;
|
||||||
|
houseNo!: string;
|
||||||
|
mobilePhone!: string;
|
||||||
|
regularPhone!: string;
|
||||||
|
managerName!: string;
|
||||||
|
managerEmail?: string;
|
||||||
|
managerPhone!: string;
|
||||||
|
|
||||||
|
constructor(data: CompanyRegistrationData) {
|
||||||
|
this.licenceNumber = data.licenceNumber;
|
||||||
|
this.statusDescription = data.statusDescription;
|
||||||
|
this.dateRegistered = data.dateRegistered;
|
||||||
|
this.renewedFrom = data.renewedFrom;
|
||||||
|
this.renewalDate = data.renewalDate;
|
||||||
|
this.renewedTo = data.renewedTo;
|
||||||
|
this.region = data.region;
|
||||||
|
this.zone = data.zone;
|
||||||
|
this.woreda = data.woreda;
|
||||||
|
this.kebele = data.kebele;
|
||||||
|
this.houseNo = data.houseNo;
|
||||||
|
this.mobilePhone = data.mobilePhone;
|
||||||
|
this.regularPhone = data.regularPhone;
|
||||||
|
this.managerName = data.managerName;
|
||||||
|
this.managerEmail = data.managerEmail;
|
||||||
|
this.managerPhone = data.managerPhone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString, IsNotEmpty, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class FetchETradeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
||||||
|
tin!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||||
|
|
||||||
|
export class ListCompaniesQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanyType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(CompanyType))
|
||||||
|
type?: CompanyType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(CompanyStatus))
|
||||||
|
status?: CompanyStatus;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export class ProfileResponseDto {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
|
nationality: string | null;
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
@@ -16,7 +17,22 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
companyProfiles: ResponseCompanyProfileDto[];
|
companyProfiles: ResponseCompanyProfileDto[];
|
||||||
|
|
||||||
|
licenceNumber: string | null;
|
||||||
|
statusDescription: string | null;
|
||||||
|
dateRegistered: string | null;
|
||||||
|
renewedFrom: string | null;
|
||||||
|
renewalDate: string | null;
|
||||||
|
renewedTo: string | null;
|
||||||
|
region: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
woreda: string | null;
|
||||||
|
kebele: string | null;
|
||||||
|
houseNo: string | null;
|
||||||
|
etradePhone: string | null;
|
||||||
|
|
||||||
contactPersonName: string | null;
|
contactPersonName: string | null;
|
||||||
|
contactPersonPosition: string | null;
|
||||||
|
contactPersonEmail: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
@@ -34,6 +50,7 @@ export class ProfileResponseDto {
|
|||||||
this.companyId = company.id;
|
this.companyId = company.id;
|
||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
this.companyType = company.type;
|
this.companyType = company.type;
|
||||||
|
this.nationality = company.nationality ?? null;
|
||||||
this.companyProfiles =
|
this.companyProfiles =
|
||||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
[];
|
[];
|
||||||
@@ -46,8 +63,23 @@ export class ProfileResponseDto {
|
|||||||
this.fanNumber = company.fanNumber ?? null;
|
this.fanNumber = company.fanNumber ?? null;
|
||||||
this.profileId = profile.id;
|
this.profileId = profile.id;
|
||||||
|
|
||||||
|
this.licenceNumber = company.licenceNumber ?? null;
|
||||||
|
this.statusDescription = company.statusDescription ?? null;
|
||||||
|
this.dateRegistered = company.dateRegistered ?? null;
|
||||||
|
this.renewedFrom = company.renewedFrom ?? null;
|
||||||
|
this.renewalDate = company.renewalDate ?? null;
|
||||||
|
this.renewedTo = company.renewedTo ?? null;
|
||||||
|
this.region = company.region ?? null;
|
||||||
|
this.zone = company.zone ?? null;
|
||||||
|
this.woreda = company.woreda ?? null;
|
||||||
|
this.kebele = company.kebele ?? null;
|
||||||
|
this.houseNo = company.houseNo ?? null;
|
||||||
|
this.etradePhone = company.etradePhone ?? null;
|
||||||
|
|
||||||
const attrs = company.attributes ?? {};
|
const attrs = company.attributes ?? {};
|
||||||
this.contactPersonName = attrs.contactPersonName ?? null;
|
this.contactPersonName = attrs.contactPersonName ?? null;
|
||||||
|
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||||
|
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
import {
|
||||||
import { CompanyProfile } from '../entities/company-profile.entity';
|
Company,
|
||||||
|
CompanyType,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyNationality,
|
||||||
|
} from '../entities/company.entity';
|
||||||
|
import {
|
||||||
|
BusinessLicenseFile,
|
||||||
|
CompanyProfile,
|
||||||
|
} from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
export class ResponseCompanyProfileDto {
|
export class ResponseCompanyProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
companyId: string;
|
||||||
type: string;
|
type: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||||
businessLicense?: string | null;
|
businessLicense?: string | null;
|
||||||
|
/** Business-license documents stored on the profile (multi-file). */
|
||||||
|
licenseFiles: BusinessLicenseFile[];
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(profile: CompanyProfile) {
|
constructor(profile: CompanyProfile) {
|
||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
|
this.companyId = profile.companyId;
|
||||||
this.type = profile.type;
|
this.type = profile.type;
|
||||||
this.reference = profile.reference;
|
this.reference = profile.reference;
|
||||||
this.status = profile.status;
|
this.status = profile.status;
|
||||||
this.businessLicense = profile.businessLicense;
|
this.businessLicense = profile.businessLicense;
|
||||||
|
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||||
this.attributes = profile.attributes;
|
this.attributes = profile.attributes;
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
@@ -29,6 +43,7 @@ export class ResponseCompanyDto {
|
|||||||
name: string;
|
name: string;
|
||||||
type: CompanyType;
|
type: CompanyType;
|
||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
|
nationality?: CompanyNationality | null;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
@@ -48,6 +63,7 @@ export class ResponseCompanyDto {
|
|||||||
this.name = company.name;
|
this.name = company.name;
|
||||||
this.type = company.type;
|
this.type = company.type;
|
||||||
this.status = company.status;
|
this.status = company.status;
|
||||||
|
this.nationality = company.nationality ?? null;
|
||||||
this.tin = company.tin;
|
this.tin = company.tin;
|
||||||
this.vatNumber = company.vatNumber;
|
this.vatNumber = company.vatNumber;
|
||||||
this.fanNumber = company.fanNumber;
|
this.fanNumber = company.fanNumber;
|
||||||
@@ -58,7 +74,9 @@ export class ResponseCompanyDto {
|
|||||||
this.website = company.website;
|
this.website = company.website;
|
||||||
this.attributes = company.attributes;
|
this.attributes = company.attributes;
|
||||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||||
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
this.companyProfiles = company.companyProfiles?.map(
|
||||||
|
(p) => new ResponseCompanyProfileDto(p),
|
||||||
|
);
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
this.updatedAt = company.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
|
import {
|
||||||
|
ExternalProfile,
|
||||||
|
} from '../entities/external-profile.entity';
|
||||||
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
|
||||||
export class ResponseExternalProfileDto {
|
export class ResponseExternalProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,10 +15,20 @@ export class ResponseExternalProfileDto {
|
|||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
|
/** The active operational mode (importer/exporter/forwarder). */
|
||||||
|
activeProfileType?: ProfileType | null;
|
||||||
|
/**
|
||||||
|
* The id of the company_profile matching activeProfileType, resolved
|
||||||
|
* server-side so the client never re-derives it. Null until a company
|
||||||
|
* (with profiles) is loaded and a matching profile exists.
|
||||||
|
*/
|
||||||
|
activeCompanyProfileId?: string | null;
|
||||||
|
onboardingStep?: string | null;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile) {
|
constructor(profile: ExternalProfile, company?: Company) {
|
||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.userId = profile.userId;
|
this.userId = profile.userId;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
@@ -25,6 +39,13 @@ export class ResponseExternalProfileDto {
|
|||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
|
this.activeProfileType = profile.activeProfileType ?? null;
|
||||||
|
this.onboardingStep = profile.onboardingStep ?? null;
|
||||||
|
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||||
|
this.activeCompanyProfileId =
|
||||||
|
company?.companyProfiles?.find(
|
||||||
|
(p) => p.type === profile.activeProfileType,
|
||||||
|
)?.id ?? null;
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
|
||||||
|
export class SetActiveModeDto {
|
||||||
|
@IsEnum(ProfileType)
|
||||||
|
type!: ProfileType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetOnboardingStepDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
step!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
|
||||||
|
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||||
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
|
export class StartOnboardingDto {
|
||||||
|
@IsEnum(CompanyType)
|
||||||
|
companyType!: CompanyType;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsEnum(ProfileType, { each: true })
|
||||||
|
roles!: ProfileType[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CompanyNationality)
|
||||||
|
nationality?: CompanyNationality;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsIn } from "class-validator";
|
||||||
|
import { ProfileStatus } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
|
export class UpdateCompanyProfileStatusDto {
|
||||||
|
@ApiProperty({ enum: ProfileStatus })
|
||||||
|
@IsIn(Object.values(ProfileStatus))
|
||||||
|
status!: ProfileStatus;
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
|
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
|
||||||
|
import { CompanyNationality } from '../entities/company.entity';
|
||||||
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CompanyNationality)
|
||||||
|
nationality?: CompanyNationality;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
@@ -14,6 +20,7 @@ export class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
|
@IsValidPhone()
|
||||||
companyPhone?: string;
|
companyPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -47,6 +54,15 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
contactPersonPosition?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
contactPersonEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsValidPhone()
|
||||||
contactPersonPhone?: string;
|
contactPersonPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -59,6 +75,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsValidPhone()
|
||||||
generalManagerPhone?: string;
|
generalManagerPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -67,6 +84,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsValidPhone()
|
||||||
poaPhone?: string;
|
poaPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -80,4 +98,64 @@ export class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
poaAddress?: string;
|
poaAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
licenceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
statusDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
dateRegistered?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
renewedFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
renewalDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
renewedTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
zone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
woreda?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
kebele?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
houseNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
@IsValidPhone()
|
||||||
|
etradePhone?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export enum ProfileStatus {
|
|||||||
Blacklisted = "blacklisted",
|
Blacklisted = "blacklisted",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A business-license document stored directly on the company profile. */
|
||||||
|
export interface BusinessLicenseFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_profiles" })
|
@Entity({ schema: "freight", name: "company_profiles" })
|
||||||
@Index(["reference"], { unique: true })
|
@Index(["reference"], { unique: true })
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
@@ -57,6 +65,14 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
})
|
})
|
||||||
businessLicense?: string | null;
|
businessLicense?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business-license documents for this profile, stored directly on the profile
|
||||||
|
* (multi-file). The bytes live in object storage; only the metadata/URLs are
|
||||||
|
* persisted here — this is intentionally NOT modelled via the FileRecord table.
|
||||||
|
*/
|
||||||
|
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||||
|
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||||
|
|
||||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export enum CompanyStatus {
|
|||||||
Blacklisted = "blacklisted",
|
Blacklisted = "blacklisted",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CompanyNationality {
|
||||||
|
Ethiopian = "ethiopian",
|
||||||
|
Foreign = "foreign",
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
@@ -47,6 +52,16 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
|
/** Whether the company is Ethiopian or Foreign — drives the required onboarding documents. */
|
||||||
|
@Column({
|
||||||
|
name: "nationality",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
enum: CompanyNationality,
|
||||||
|
})
|
||||||
|
nationality?: CompanyNationality | null;
|
||||||
|
|
||||||
@Column({ name: "address", type: "text", nullable: true })
|
@Column({ name: "address", type: "text", nullable: true })
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
|
||||||
@@ -102,6 +117,67 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "licence_number",
|
||||||
|
type: "varchar",
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
licenceNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "status_description", type: "text", nullable: true })
|
||||||
|
statusDescription?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "date_registered",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
dateRegistered?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "renewed_from",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
renewedFrom?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "renewal_date",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
renewalDate?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "renewed_to",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
renewedTo?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "region", type: "varchar", length: 100, nullable: true })
|
||||||
|
region?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "zone", type: "varchar", length: 100, nullable: true })
|
||||||
|
zone?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "woreda", type: "varchar", length: 100, nullable: true })
|
||||||
|
woreda?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "kebele", type: "varchar", length: 100, nullable: true })
|
||||||
|
kebele?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "house_no", type: "varchar", length: 100, nullable: true })
|
||||||
|
houseNo?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
||||||
|
etradePhone?: string | null;
|
||||||
|
|
||||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||||
profiles?: ExternalProfile[];
|
profiles?: ExternalProfile[];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { Company } from './company.entity';
|
import { Company } from './company.entity';
|
||||||
|
import { ProfileType } from './company-profile.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
@@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||||
isPrimaryContact!: boolean;
|
isPrimaryContact!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operational profile the user is currently "in" (importer vs exporter,
|
||||||
|
* or the single forwarder profile). Drives header switching and scopes the
|
||||||
|
* customer's bookings / dashboard to that company_profile. Nullable for
|
||||||
|
* users who haven't picked a role yet.
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'active_profile_type',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
enum: ProfileType,
|
||||||
|
})
|
||||||
|
activeProfileType?: ProfileType | null;
|
||||||
|
|
||||||
|
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||||
|
@Column({
|
||||||
|
name: 'onboarding_step',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 40,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
onboardingStep?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'onboarding_completed', type: 'boolean', default: false })
|
||||||
|
onboardingCompleted!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import { Agent } from "https";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import {
|
||||||
|
ETradeCompanyInfo,
|
||||||
|
ETradeBusinessInfo,
|
||||||
|
CompanyRegistrationData,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ETradeService {
|
||||||
|
private readonly baseUrl = "https://etrade.gov.et/api";
|
||||||
|
private readonly referer = "https://etrade.gov.et/business-license-checker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eTrade server serves an incomplete TLS chain (it omits the intermediate
|
||||||
|
* CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT.
|
||||||
|
* Scope a relaxed agent to these outbound calls only — the rest of the app
|
||||||
|
* keeps full certificate verification.
|
||||||
|
*/
|
||||||
|
private readonly httpsAgent = new Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
|
constructor(private readonly httpService: HttpService) {}
|
||||||
|
|
||||||
|
async getCompanyInfoByTin(tin: string): Promise<ETradeCompanyInfo> {
|
||||||
|
const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`;
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<ETradeCompanyInfo>(url, {
|
||||||
|
headers: { Referer: this.referer },
|
||||||
|
httpsAgent: this.httpsAgent,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Failed to fetch company info from eTrade: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBusinessByLicenseNo(
|
||||||
|
licenseNo: string,
|
||||||
|
tin: string,
|
||||||
|
): Promise<ETradeBusinessInfo> {
|
||||||
|
const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`;
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<ETradeBusinessInfo>(url, {
|
||||||
|
params: {
|
||||||
|
LicenseNo: licenseNo,
|
||||||
|
Tin: tin,
|
||||||
|
Lang: "en",
|
||||||
|
},
|
||||||
|
headers: { Referer: this.referer },
|
||||||
|
httpsAgent: this.httpsAgent,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Failed to fetch business info from eTrade: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveCompanyData(tin: string): Promise<{
|
||||||
|
companyInfo: ETradeCompanyInfo;
|
||||||
|
businessInfo: ETradeBusinessInfo | null;
|
||||||
|
}> {
|
||||||
|
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||||
|
|
||||||
|
if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) {
|
||||||
|
return { companyInfo, businessInfo: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestBusiness = companyInfo.Businesses[0];
|
||||||
|
try {
|
||||||
|
const businessInfo = await this.getBusinessByLicenseNo(
|
||||||
|
latestBusiness.LicenceNumber,
|
||||||
|
tin,
|
||||||
|
);
|
||||||
|
return { companyInfo, businessInfo };
|
||||||
|
} catch {
|
||||||
|
return { companyInfo, businessInfo: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractRegistrationData(
|
||||||
|
businessInfo: ETradeBusinessInfo,
|
||||||
|
): CompanyRegistrationData {
|
||||||
|
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
licenceNumber: businessInfo.LicenceNumber,
|
||||||
|
statusDescription: businessInfo.StatusDescription,
|
||||||
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
|
renewedFrom: businessInfo.RenewedFrom,
|
||||||
|
renewalDate: businessInfo.RenewalDate,
|
||||||
|
renewedTo: businessInfo.RenewedTo,
|
||||||
|
region: businessInfo.AddressInfo?.Region || "",
|
||||||
|
zone: businessInfo.AddressInfo?.Zone || "",
|
||||||
|
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||||
|
kebele: businessInfo.AddressInfo?.Kebele || "",
|
||||||
|
houseNo: businessInfo.AddressInfo?.HouseNo || "",
|
||||||
|
mobilePhone: businessInfo.AddressInfo?.MobilePhone || "",
|
||||||
|
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||||
|
managerName: primaryManager?.ManagerNameEng || "",
|
||||||
|
managerPhone: primaryManager?.RegularPhone || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
@@ -33,6 +34,14 @@ import {
|
|||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
||||||
|
findByCompanyCustomerView(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
) {
|
||||||
|
return this.paymentService.findByCompanyId(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("summary")
|
@Get("summary")
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
|
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
|
|||||||
import { InternalPaymentController } from "./internal-payment.controller";
|
import { InternalPaymentController } from "./internal-payment.controller";
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||||
|
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
|||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
||||||
RabbitMQModule.forRootAsync({
|
RabbitMQModule.forRootAsync({
|
||||||
|
|||||||
@@ -61,4 +61,56 @@ export class PaymentRepository {
|
|||||||
return this.paymentRepo.createQueryBuilder(alias);
|
return this.paymentRepo.createQueryBuilder(alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
merchantOrderId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
|
status: string;
|
||||||
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}[]> {
|
||||||
|
const rows: {
|
||||||
|
id: string;
|
||||||
|
merchant_order_id: string;
|
||||||
|
booking_reference: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
|
status: string;
|
||||||
|
paid_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}[] = await this.dataSource.query(
|
||||||
|
`SELECT p.id,
|
||||||
|
p.merchant_order_id,
|
||||||
|
b.reference AS booking_reference,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.method,
|
||||||
|
p.status,
|
||||||
|
p.paid_at,
|
||||||
|
p.created_at
|
||||||
|
FROM freight.payments p
|
||||||
|
JOIN freight.bookings b ON b.id = p.ref_id
|
||||||
|
WHERE b.company_id = $1
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
[companyId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
merchantOrderId: r.merchant_order_id,
|
||||||
|
bookingReference: r.booking_reference,
|
||||||
|
amount: Number(r.amount),
|
||||||
|
currency: r.currency,
|
||||||
|
method: r.method,
|
||||||
|
status: r.status,
|
||||||
|
paidAt: r.paid_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,11 @@ import {
|
|||||||
RefundDto,
|
RefundDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
|
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||||
|
|
||||||
|
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||||
|
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||||
|
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
@@ -54,8 +59,23 @@ export class PaymentService {
|
|||||||
private readonly paymentClient: PaymentClientService,
|
private readonly paymentClient: PaymentClientService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
private readonly dropdownSettings: DropdownSettingsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||||
|
private async contractPeriodMonths(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const setting = await this.dropdownSettings.getByCode(
|
||||||
|
CONTRACT_PERIOD_SETTING_CODE,
|
||||||
|
);
|
||||||
|
const months = Number(setting.children?.[0]?.value);
|
||||||
|
if (Number.isFinite(months) && months > 0) return months;
|
||||||
|
} catch {
|
||||||
|
// Setting not seeded — fall back to the default.
|
||||||
|
}
|
||||||
|
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||||
|
}
|
||||||
|
|
||||||
async getAll(filters: {
|
async getAll(filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -293,15 +313,44 @@ export class PaymentService {
|
|||||||
|
|
||||||
const paidAt = input.paidAt ?? new Date();
|
const paidAt = input.paidAt ?? new Date();
|
||||||
|
|
||||||
|
// A general contract is paid once, up front; it does NOT enter the train
|
||||||
|
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
||||||
|
// opens its ordering window. Orders placed later spawn their own paid
|
||||||
|
// child bookings that go through the normal pipeline.
|
||||||
|
const booking = await this.datasource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: input.bookingId } });
|
||||||
|
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
||||||
|
|
||||||
|
let contractExpiresAt: Date | null = null;
|
||||||
|
if (isGeneralContract) {
|
||||||
|
const months = await this.contractPeriodMonths();
|
||||||
|
contractExpiresAt = new Date(paidAt);
|
||||||
|
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
||||||
|
}
|
||||||
|
|
||||||
await this.datasource.transaction(async (mg) => {
|
await this.datasource.transaction(async (mg) => {
|
||||||
await mg.update(
|
await mg.update(
|
||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
{ id: intent.id },
|
{ id: intent.id },
|
||||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||||
);
|
);
|
||||||
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
|
await mg.update(
|
||||||
|
Booking,
|
||||||
|
{ id: input.bookingId },
|
||||||
|
isGeneralContract
|
||||||
|
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||||
|
: { paymentStatus: "PAID", status: "PAID" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isGeneralContract) {
|
||||||
|
this.logger.log(
|
||||||
|
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||||
|
);
|
||||||
|
return { alreadyFinalized: false };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -428,4 +477,8 @@ export class PaymentService {
|
|||||||
default: return "action-required";
|
default: return "action-required";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId: string) {
|
||||||
|
return this.paymentRepo.findByCompanyId(companyId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
|
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateCargoTypeDto {
|
export class CreateCargoTypeDto {
|
||||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||||
@@ -7,6 +8,14 @@ export class CreateCargoTypeDto {
|
|||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
cargoTypeName!: string;
|
cargoTypeName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CargoUnitOfMeasure,
|
||||||
|
description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CargoUnitOfMeasure)
|
||||||
|
unitOfMeasure?: CargoUnitOfMeasure;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
|
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||||
@@ -19,6 +20,14 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||||
showFreeTextBox!: boolean;
|
showFreeTextBox!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||||
|
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||||
|
* container. Drives the unit shown when ordering against a general contract.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
||||||
|
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||||
|
|
||||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||||
requiresDirectorApproval!: boolean;
|
requiresDirectorApproval!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export class CargoTypesService {
|
|||||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export interface SchedulingPriorityBooking {
|
export interface SchedulingPriorityBooking {
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
priorityScore?: number | null;
|
priorityScore?: number | null;
|
||||||
scheduledDate: Date | string;
|
// One-time bookings always carry a date; general contracts (never scheduled)
|
||||||
|
// may be null — treated as epoch 0 so they sort last.
|
||||||
|
scheduledDate?: Date | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Government first, then priority score, then earliest scheduled date. */
|
/** Government first, then priority score, then earliest scheduled date. */
|
||||||
@@ -15,5 +17,7 @@ export function compareSchedulingPriority(
|
|||||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||||
if (priorityDiff !== 0) return priorityDiff;
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||||
|
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||||
|
return aTime - bTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */
|
||||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||||
// export const BATCH_CRON = '*/3 * * * *';
|
// export const BATCH_CRON = '*/3 * * * *';
|
||||||
export const BATCH_CRON = '*/5 * * * *';
|
export const BATCH_CRON = '*/5 * * * *';
|
||||||
|
// export const BATCH_CRON = '0 */3 * * *';//
|
||||||
|
|
||||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
|||||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||||
if (priorityDiff !== 0) return priorityDiff;
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||||
|
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||||
|
return aTime - bTime;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1880,7 +1880,7 @@ export class TrainSchedulingService {
|
|||||||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||||
destination:
|
destination:
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||||
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { AppDataSource } from "../data-source";
|
||||||
|
import { FileUploadSettingsSeeder } from "../seed/file-upload-settings.seeder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently (re)seed the company onboarding file-upload settings, including
|
||||||
|
* the nationality-based document sets (ethiopian / foreign). Run on demand:
|
||||||
|
* pnpm --filter @edr/freight-api seed:file-upload-settings
|
||||||
|
*/
|
||||||
|
async function run() {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
try {
|
||||||
|
const seeder = new FileUploadSettingsSeeder(AppDataSource);
|
||||||
|
await seeder.run();
|
||||||
|
console.log("Seeded company onboarding file-upload settings.");
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Failed to seed file-upload settings:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -4,33 +4,107 @@ import { DataSource } from "typeorm";
|
|||||||
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||||
|
|
||||||
const COMPANY_ONBOARDING_DOCUMENTS = [
|
interface OnboardingField {
|
||||||
{
|
fileKey: string;
|
||||||
code: "company_onboarding_documents_customer",
|
fileLabel: string;
|
||||||
label: "Customer onboarding documents",
|
helpText: string;
|
||||||
entity: "customer",
|
isRequired: boolean;
|
||||||
},
|
isMultiple: boolean;
|
||||||
{
|
maxFiles: number;
|
||||||
code: "company_onboarding_documents_forwarder",
|
allowedExtensions: string[];
|
||||||
label: "Forwarder onboarding documents",
|
maxSizeMb: number;
|
||||||
entity: "other",
|
displayOrder: number;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
code: "company_onboarding_documents_transporter",
|
|
||||||
label: "Transporter onboarding documents",
|
|
||||||
entity: "other",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "company_onboarding_documents_forwarder_dj",
|
|
||||||
label: "Djibouti forwarder onboarding documents",
|
|
||||||
entity: "other",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
||||||
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
|
|
||||||
|
|
||||||
const COMPANY_ONBOARDING_FIELDS = [
|
/** Documents required from an Ethiopian company at onboarding. */
|
||||||
|
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
|
{
|
||||||
|
fileKey: "tin_certificate",
|
||||||
|
fileLabel: "TIN Certificate",
|
||||||
|
helpText: "Verified against the TIN registry during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "commercial_license",
|
||||||
|
fileLabel: "Commercial License",
|
||||||
|
helpText: "Verified against the government trade system during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "national_id",
|
||||||
|
fileLabel: "National ID",
|
||||||
|
helpText: "Verified against the National ID API during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Documents required from a Foreign company at onboarding. */
|
||||||
|
const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
|
{
|
||||||
|
fileKey: "tin_certificate",
|
||||||
|
fileLabel: "TIN Certificate",
|
||||||
|
helpText: "Verified against the TIN registry during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "investment_license",
|
||||||
|
fileLabel: "Investment License",
|
||||||
|
helpText: "Investment license issued for operating in Ethiopia.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "national_id",
|
||||||
|
fileLabel: "National ID",
|
||||||
|
helpText: "National ID of the company's authorized representative.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "passport",
|
||||||
|
fileLabel: "Passport",
|
||||||
|
helpText: "Passport of the company's authorized representative.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||||
|
const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
{
|
{
|
||||||
fileKey: "business_license",
|
fileKey: "business_license",
|
||||||
fileLabel: "Business License / Trade License",
|
fileLabel: "Business License / Trade License",
|
||||||
@@ -38,7 +112,7 @@ const COMPANY_ONBOARDING_FIELDS = [
|
|||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 1,
|
displayOrder: 1,
|
||||||
},
|
},
|
||||||
@@ -49,7 +123,7 @@ const COMPANY_ONBOARDING_FIELDS = [
|
|||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 2,
|
displayOrder: 2,
|
||||||
},
|
},
|
||||||
@@ -60,11 +134,63 @@ const COMPANY_ONBOARDING_FIELDS = [
|
|||||||
isRequired: true,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
|
interface OnboardingDocumentSetting {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
entity: string;
|
||||||
|
fields: OnboardingField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
||||||
|
// Nationality-based sets — the document requirements depend only on whether
|
||||||
|
// the company is Ethiopian or Foreign (same for importer/exporter/forwarder).
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_ethiopian",
|
||||||
|
label: "Ethiopian company onboarding documents",
|
||||||
|
entity: "customer",
|
||||||
|
fields: ETHIOPIAN_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_foreign",
|
||||||
|
label: "Foreign company onboarding documents",
|
||||||
|
entity: "customer",
|
||||||
|
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
// Legacy per-company-type codes (kept for back-compat; no longer used by the portal).
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_customer",
|
||||||
|
label: "Customer onboarding documents",
|
||||||
|
entity: "customer",
|
||||||
|
fields: LEGACY_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_forwarder",
|
||||||
|
label: "Forwarder onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
fields: LEGACY_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_transporter",
|
||||||
|
label: "Transporter onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
fields: LEGACY_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_forwarder_dj",
|
||||||
|
label: "Djibouti forwarder onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
fields: LEGACY_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||||
|
"Required documents for external company onboarding, by company nationality.";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
@@ -102,7 +228,7 @@ export class FileUploadSettingsSeeder {
|
|||||||
await fieldRepository.delete({ settingId: setting.id });
|
await fieldRepository.delete({ settingId: setting.id });
|
||||||
|
|
||||||
await fieldRepository.insert(
|
await fieldRepository.insert(
|
||||||
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
|
documentSetting.fields.map((field, index) => ({
|
||||||
settingId: setting.id,
|
settingId: setting.id,
|
||||||
fileKey: field.fileKey,
|
fileKey: field.fileKey,
|
||||||
fileLabel: field.fileLabel,
|
fileLabel: field.fileLabel,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Building2,
|
||||||
Container,
|
Container,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -27,6 +28,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
|||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
@@ -89,6 +92,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Customers",
|
||||||
|
href: "/dashboard/customers",
|
||||||
|
icon: <Building2 />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Payments",
|
label: "Payments",
|
||||||
href: "/dashboard/payments",
|
href: "/dashboard/payments",
|
||||||
@@ -360,6 +368,8 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCargoesByContainer, useDeliverCargo, useUnloadCargo } from '@/hooks/useCargoes';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -6,9 +7,14 @@ import { LoadCargoDialog } from './LoadCargoDialog';
|
|||||||
import type { Cargo } from '@/services/cargoService';
|
import type { Cargo } from '@/services/cargoService';
|
||||||
|
|
||||||
export function CargoesTable({ containerId }: { containerId: string }) {
|
export function CargoesTable({ containerId }: { containerId: string }) {
|
||||||
const { data: cargoes, refetch } = useCargoesByContainer(containerId);
|
const { data: cargoes, refetch } = useQuery(
|
||||||
const deliver = useDeliverCargo();
|
api.cargoes.listByContainer.queryOptions({
|
||||||
const unload = useUnloadCargo();
|
input: { containerId },
|
||||||
|
enabled: !!containerId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const deliver = useMutation(api.cargoes.deliver.mutationOptions());
|
||||||
|
const unload = useMutation(api.cargoes.unload.mutationOptions());
|
||||||
|
|
||||||
if (!cargoes?.length) return <div className="text-muted-foreground">No cargoes for this container.</div>;
|
if (!cargoes?.length) return <div className="text-muted-foreground">No cargoes for this container.</div>;
|
||||||
|
|
||||||
@@ -34,8 +40,8 @@ export function CargoesTable({ containerId }: { containerId: string }) {
|
|||||||
<TableCell><Badge variant="outline">{cargo.status}</Badge></TableCell>
|
<TableCell><Badge variant="outline">{cargo.status}</Badge></TableCell>
|
||||||
<TableCell className="space-x-2">
|
<TableCell className="space-x-2">
|
||||||
{cargo.status === 'PENDING' && <LoadCargoDialog cargoId={cargo.id} onSuccess={() => refetch()} />}
|
{cargo.status === 'PENDING' && <LoadCargoDialog cargoId={cargo.id} onSuccess={() => refetch()} />}
|
||||||
{cargo.status === 'LOADED' && <Button size="sm" onClick={() => deliver.mutateAsync(cargo.id).then(() => refetch())}>Deliver</Button>}
|
{cargo.status === 'LOADED' && <Button size="sm" onClick={() => deliver.mutateAsync({ id: cargo.id }).then(() => refetch())}>Deliver</Button>}
|
||||||
{cargo.status === 'LOADED' && <Button size="sm" variant="outline" onClick={() => unload.mutateAsync(cargo.id).then(() => refetch())}>Unload</Button>}
|
{cargo.status === 'LOADED' && <Button size="sm" variant="outline" onClick={() => unload.mutateAsync({ id: cargo.id }).then(() => refetch())}>Unload</Button>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useDeliverCargo } from '@/hooks/useCargoes';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +19,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
|||||||
const [receiverName, setReceiverName] = useState('');
|
const [receiverName, setReceiverName] = useState('');
|
||||||
const [pickupDate, setPickupDate] = useState('');
|
const [pickupDate, setPickupDate] = useState('');
|
||||||
const [deliveryRemarks, setDeliveryRemarks] = useState('');
|
const [deliveryRemarks, setDeliveryRemarks] = useState('');
|
||||||
const deliver = useDeliverCargo();
|
const deliver = useMutation(api.cargoes.deliver.mutationOptions());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const handleDeliver = async () => {
|
const handleDeliver = async () => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useLoadCargo } from '@/hooks/useCargoes';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
|
export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
|
||||||
@@ -11,7 +12,7 @@ export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuc
|
|||||||
const [quantity, setQuantity] = useState(0);
|
const [quantity, setQuantity] = useState(0);
|
||||||
const [weight, setWeight] = useState(0);
|
const [weight, setWeight] = useState(0);
|
||||||
const [volume, setVolume] = useState<number>();
|
const [volume, setVolume] = useState<number>();
|
||||||
const load = useLoadCargo();
|
const load = useMutation(api.cargoes.load.mutationOptions());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const handleLoad = async () => {
|
const handleLoad = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Box, Card } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface TableCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
||||||
|
* always `width: 100%`, so without a floor it can never overflow its
|
||||||
|
* container and the horizontal scroll never engages. Setting a floor lets
|
||||||
|
* columns keep a sensible width and the card scroll horizontally on narrow
|
||||||
|
* viewports instead of squishing.
|
||||||
|
*/
|
||||||
|
minWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
|
||||||
|
* single child is a horizontally scrollable region. Pair with the table's
|
||||||
|
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
|
||||||
|
* the customer pages reads identically (same surface, same scroll behaviour).
|
||||||
|
*/
|
||||||
|
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
|
||||||
|
return (
|
||||||
|
<Card p={0}>
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={minWidth}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableCard;
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CompanyProfile,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
CustomerBookingStatus,
|
||||||
|
CustomerPaymentStatus,
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "@/types/customer";
|
||||||
|
|
||||||
|
import { humanize } from "./format";
|
||||||
|
|
||||||
|
const badgeStyle = {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared status palette — active/paid green, pending amber, terminal red. */
|
||||||
|
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||||
|
active: "edr-green",
|
||||||
|
pending: "yellow",
|
||||||
|
suspended: "orange",
|
||||||
|
blacklisted: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY_TYPE_COLOR: Record<CompanyType, string> = {
|
||||||
|
customer: "edr-green",
|
||||||
|
freight_forwarder: "blue",
|
||||||
|
dj_freight_forwarder: "indigo",
|
||||||
|
transporter: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
|
||||||
|
importer: "teal",
|
||||||
|
exporter: "cyan",
|
||||||
|
freight_forwarder: "blue",
|
||||||
|
dj_freight_forwarder: "indigo",
|
||||||
|
transporter: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyTypeBadge({ type }: { type: CompanyType }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={COMPANY_TYPE_COLOR[type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(type)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
||||||
|
* carrying its reference code. Caps at three (a company has at most three
|
||||||
|
* profiles); any extra collapse into a `+N` chip.
|
||||||
|
*/
|
||||||
|
export function ProfileChips({
|
||||||
|
profiles,
|
||||||
|
max = 3,
|
||||||
|
}: {
|
||||||
|
profiles: CompanyProfile[];
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
if (!profiles.length) {
|
||||||
|
return (
|
||||||
|
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||||
|
No profiles
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = profiles.slice(0, max);
|
||||||
|
const extra = profiles.length - shown.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{shown.map((profile) => (
|
||||||
|
<Tooltip
|
||||||
|
key={profile.id}
|
||||||
|
label={`${humanize(profile.type)} · ${humanize(profile.status)}`}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(profile.type)} · {profile.reference}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{extra > 0 ? (
|
||||||
|
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||||
|
+{extra}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileTypeBadge({ type }: { type: ProfileType }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={PROFILE_TYPE_COLOR[type] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(type)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileStatusBadge({ status }: { status: ProfileStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
SUBMITTED: "yellow",
|
||||||
|
PENDING_APPROVAL: "yellow",
|
||||||
|
APPROVED: "cyan",
|
||||||
|
PAID: "edr-green",
|
||||||
|
IN_TRANSIT: "blue",
|
||||||
|
COMPLETED: "indigo",
|
||||||
|
REJECTED: "red",
|
||||||
|
CANCELLED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
||||||
|
"action-required": "orange",
|
||||||
|
processing: "yellow",
|
||||||
|
success: "edr-green",
|
||||||
|
failed: "red",
|
||||||
|
canceled: "gray",
|
||||||
|
refunded: "grape",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline approval action buttons for a profile row.
|
||||||
|
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||||
|
*/
|
||||||
|
export function ProfileApprovalActions({
|
||||||
|
profileId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
profileId: string;
|
||||||
|
status: ProfileStatus;
|
||||||
|
}) {
|
||||||
|
const { mutate, isPending } = useMutation(
|
||||||
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const act = (next: ProfileStatus) =>
|
||||||
|
mutate({ profileId, status: next });
|
||||||
|
|
||||||
|
if (status === "pending") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("active")}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("blacklisted")}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "active") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("suspended")}
|
||||||
|
>
|
||||||
|
Suspend
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "suspended") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("active")}
|
||||||
|
>
|
||||||
|
Reactivate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("blacklisted")}
|
||||||
|
>
|
||||||
|
Blacklist
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "blacklisted") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("pending")}
|
||||||
|
>
|
||||||
|
Reinstate
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/** Shared formatting helpers for the customer-management pages. */
|
||||||
|
|
||||||
|
/** snake_case / SCREAMING_CASE → Title Case. */
|
||||||
|
export function humanize(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[_\s]+/)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
BookingStatusBadge,
|
||||||
|
CompanyStatusBadge,
|
||||||
|
CompanyTypeBadge,
|
||||||
|
PaymentStatusBadge,
|
||||||
|
ProfileApprovalActions,
|
||||||
|
ProfileChips,
|
||||||
|
ProfileStatusBadge,
|
||||||
|
ProfileTypeBadge,
|
||||||
|
} from "./badges";
|
||||||
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileSignature, Loader2 } from "lucide-react";
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -10,10 +13,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import {
|
|
||||||
useMySignature,
|
|
||||||
useSaveSignature,
|
|
||||||
} from "@/hooks/useSavedSignature";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -33,8 +32,10 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function MySignatureCard() {
|
export function MySignatureCard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: saved, isLoading } = useMySignature();
|
const { data: saved, isLoading } = useQuery(
|
||||||
const saveMutation = useSaveSignature();
|
api.signatures.mySignature.queryOptions({ staleTime: 60_000 }),
|
||||||
|
);
|
||||||
|
const saveMutation = useMutation(api.signatures.save.mutationOptions());
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
@@ -56,7 +57,13 @@ export function MySignatureCard() {
|
|||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: signatureData,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => setOpen(false) },
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signature saved");
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save signature"),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,8 @@ import {
|
|||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
useAvailableLocomotives,
|
import { api } from "@/services/api";
|
||||||
useEligibleBookings,
|
|
||||||
useScheduleList,
|
|
||||||
useScheduleMutations,
|
|
||||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
|
||||||
import { useRoutes } from "@/hooks/useRoutes";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
@@ -133,13 +128,27 @@ export function AllocateBookingWizard({
|
|||||||
[originId, destinationId],
|
[originId, destinationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
const eligibleQuery = useQuery(
|
||||||
const schedulesQuery = useScheduleList();
|
api.trainScheduling.eligibleBookings.queryOptions({
|
||||||
const routesQuery = useRoutes();
|
input: { filters: eligibleFilters },
|
||||||
const locomotivesQuery = useAvailableLocomotives(
|
enabled: opened,
|
||||||
scheduleMode === "new" && routeId ? routeId : undefined,
|
}),
|
||||||
);
|
);
|
||||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
const schedulesQuery = useQuery(
|
||||||
|
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
||||||
|
);
|
||||||
|
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||||
|
const locomotivesQuery = useQuery(
|
||||||
|
api.trainScheduling.availableLocomotives.queryOptions({
|
||||||
|
input: {
|
||||||
|
routeId: scheduleMode === "new" && routeId ? routeId : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
|
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||||
|
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||||
|
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scheduleMode === "new") {
|
if (scheduleMode === "new") {
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
|
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import {
|
import { api } from "@/services/api";
|
||||||
useBatchActions,
|
|
||||||
useBookableSchedules,
|
|
||||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -33,16 +32,31 @@ const windowColor: Record<string, string> = {
|
|||||||
|
|
||||||
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
|
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const actions = useBatchActions(schedule.id);
|
const actions = {
|
||||||
|
runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()),
|
||||||
|
setWindow: useMutation(api.trainScheduling.setBookingWindow.mutationOptions()),
|
||||||
|
markPaid: useMutation(api.trainScheduling.markBookingPaid.mutationOptions()),
|
||||||
|
expire: useMutation(api.trainScheduling.expireBooking.mutationOptions()),
|
||||||
|
moveSchedule: useMutation(
|
||||||
|
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||||
|
),
|
||||||
|
};
|
||||||
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
|
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
|
||||||
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
|
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
|
||||||
|
|
||||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: targets } = useBookableSchedules(
|
const { data: targets } = useQuery(
|
||||||
schedule.originStation?.id,
|
api.trainScheduling.bookableSchedules.queryOptions({
|
||||||
schedule.destinationStation?.id,
|
input: {
|
||||||
|
originYardId: schedule.originStation?.id,
|
||||||
|
destinationYardId: schedule.destinationStation?.id,
|
||||||
|
},
|
||||||
|
enabled: Boolean(
|
||||||
|
schedule.originStation?.id && schedule.destinationStation?.id,
|
||||||
|
),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const moveOptions = useMemo(
|
const moveOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Building2, Package, TrainFront, Weight, X } from "lucide-react";
|
|||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import type { BookingDetailData } from "./BookingDetailModal";
|
import type { BookingDetailData } from "./BookingDetailModal";
|
||||||
import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal";
|
import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal";
|
||||||
import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ export const AssignedBookingsPanel = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: AssignedBookingsPanelProps) => {
|
}: AssignedBookingsPanelProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const unassign = useScheduleMutations(scheduleId).unassign;
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||||
const isDispatched = scheduleDetail.status === "DISPATCHED";
|
const isDispatched = scheduleDetail.status === "DISPATCHED";
|
||||||
const [removalTarget, setRemovalTarget] = useState<RemovalTarget | null>(null);
|
const [removalTarget, setRemovalTarget] = useState<RemovalTarget | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
|
|||||||
import { RemovalLogPanel } from "./RemovalLogPanel";
|
import { RemovalLogPanel } from "./RemovalLogPanel";
|
||||||
import { BatchBookingList } from "./BatchBookingList";
|
import { BatchBookingList } from "./BatchBookingList";
|
||||||
import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal";
|
import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
useCompositionRemovals,
|
import { api } from "@/services/api";
|
||||||
useUnassignedBookings,
|
|
||||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
interface CompositionBookingTabsProps {
|
interface CompositionBookingTabsProps {
|
||||||
@@ -47,8 +45,18 @@ export const CompositionBookingTabs = ({
|
|||||||
const [detailBooking, setDetailBooking] = useState<BookingDetailData | null>(null);
|
const [detailBooking, setDetailBooking] = useState<BookingDetailData | null>(null);
|
||||||
const [tab, setTab] = useState<TabKey>("assigned");
|
const [tab, setTab] = useState<TabKey>("assigned");
|
||||||
|
|
||||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
const unassignedQuery = useQuery(
|
||||||
const removalsQuery = useCompositionRemovals(scheduleId);
|
api.trainScheduling.unassignedBookings.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const removalsQuery = useQuery(
|
||||||
|
api.trainScheduling.compositionRemovals.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { assignedCount } = useMemo(() => {
|
const { assignedCount } = useMemo(() => {
|
||||||
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Group, TextInput, Text } from "@mantine/core";
|
import { Group, TextInput, Text } from "@mantine/core";
|
||||||
import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
interface ContainerNumberInputProps {
|
interface ContainerNumberInputProps {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -19,13 +20,16 @@ export const ContainerNumberInput = ({
|
|||||||
const [inputValue, setInputValue] = useState(value ?? "");
|
const [inputValue, setInputValue] = useState(value ?? "");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const updateMutation = useUpdateContainerItem(scheduleId);
|
const updateMutation = useMutation(
|
||||||
|
api.trainScheduling.updateContainerItem.mutationOptions(),
|
||||||
|
);
|
||||||
const isLoading = updateMutation.isPending;
|
const isLoading = updateMutation.isPending;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({
|
||||||
|
scheduleId,
|
||||||
itemId,
|
itemId,
|
||||||
containerNumber: inputValue || null,
|
containerNumber: inputValue || null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { History, PackageX } from "lucide-react";
|
import { History, PackageX } from "lucide-react";
|
||||||
import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
interface RemovalLogPanelProps {
|
interface RemovalLogPanelProps {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => {
|
export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => {
|
||||||
const removalQuery = useCompositionRemovals(scheduleId);
|
const removalQuery = useQuery(
|
||||||
|
api.trainScheduling.compositionRemovals.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (removalQuery.isLoading) {
|
if (removalQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { TrainStatsBar } from "./TrainStatsBar";
|
|||||||
import { WagonCard } from "./WagonCard";
|
import { WagonCard } from "./WagonCard";
|
||||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||||
import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||||
@@ -47,8 +48,12 @@ export const TrainConsistView = ({
|
|||||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||||
|
|
||||||
const unassignMutation = useScheduleMutations(scheduleId).unassign;
|
const unassignMutation = useMutation(
|
||||||
const removeWagonMutation = useRemoveWagonSlot(scheduleId);
|
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||||
|
);
|
||||||
|
const removeWagonMutation = useMutation(
|
||||||
|
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const trainSet = scheduleDetail.trainSet;
|
const trainSet = scheduleDetail.trainSet;
|
||||||
const wagons = trainSet?.wagons ?? [];
|
const wagons = trainSet?.wagons ?? [];
|
||||||
@@ -83,7 +88,7 @@ export const TrainConsistView = ({
|
|||||||
|
|
||||||
const handleRemoveWagon = async (wagonId: string) => {
|
const handleRemoveWagon = async (wagonId: string) => {
|
||||||
if (confirm("Are you sure you want to remove this wagon slot?")) {
|
if (confirm("Are you sure you want to remove this wagon slot?")) {
|
||||||
await removeWagonMutation.mutateAsync(wagonId);
|
await removeWagonMutation.mutateAsync({ scheduleId, wagonId });
|
||||||
setSelectedWagonId(null);
|
setSelectedWagonId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||||
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
|
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
|
||||||
import {
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
useUnassignedBookings,
|
import { api } from "@/services/api";
|
||||||
useScheduleMutations,
|
|
||||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
|
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
|
||||||
import type { BookingDetailData } from "./BookingDetailModal";
|
import type { BookingDetailData } from "./BookingDetailModal";
|
||||||
@@ -73,8 +71,15 @@ export const UnassignedBookingsPanel = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: UnassignedBookingsPanelProps) => {
|
}: UnassignedBookingsPanelProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
const unassignedQuery = useQuery(
|
||||||
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
|
api.trainScheduling.unassignedBookings.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assignMutation = useMutation(
|
||||||
|
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const handleAssign = async (bookingId: string, reference: string | null) => {
|
const handleAssign = async (bookingId: string, reference: string | null) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine
|
|||||||
|
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useRouteYards } from "@/hooks/useRoutes";
|
|
||||||
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
|
|
||||||
|
|
||||||
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [wagonId, setWagonId] = useState<string | null>(null);
|
const [wagonId, setWagonId] = useState<string | null>(null);
|
||||||
const [sequence, setSequence] = useState<number | "">("");
|
const [sequence, setSequence] = useState<number | "">("");
|
||||||
const { data: wagons } = useWagons();
|
const { data: wagons } = useQuery(api.wagons.list.queryOptions({ input: {} }));
|
||||||
const { data: yards = [] } = useRouteYards();
|
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
|
||||||
const assign = useAssignWagonToTrain();
|
const assign = useMutation(api.wagons.assignToTrain.mutationOptions());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const available = (wagons ?? []).filter(
|
const available = (wagons ?? []).filter(
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ import { Trash2 } from "lucide-react";
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons";
|
|
||||||
import type { Wagon } from "@/services/wagon.service";
|
import type { Wagon } from "@/services/wagon.service";
|
||||||
import { DataTable } from "@edr/ui-common";
|
import { DataTable } from "@edr/ui-common";
|
||||||
|
|
||||||
export function WagonsTable({ trainId }: { trainId: string }) {
|
export function WagonsTable({ trainId }: { trainId: string }) {
|
||||||
const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId);
|
const { data: wagons = [], isLoading, refetch } = useQuery(
|
||||||
const unassign = useUnassignWagon();
|
api.wagons.listByTrain.queryOptions({
|
||||||
|
input: { trainId },
|
||||||
|
enabled: !!trainId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const unassign = useMutation(api.wagons.unassign.mutationOptions());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const columns = useMemo((): ColumnDef<Wagon>[] => {
|
const columns = useMemo((): ColumnDef<Wagon>[] => {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { useInventoryActivity } from '@/hooks/useWarehouses';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
import type { ActivityType } from '@/types/warehouse';
|
import type { ActivityType } from '@/types/warehouse';
|
||||||
import { formatDate, humanizeEnum } from './options';
|
import { formatDate, humanizeEnum } from './options';
|
||||||
|
|
||||||
@@ -24,7 +26,12 @@ const activityIcon: Record<ActivityType, React.ReactNode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
|
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
|
||||||
const { data, isLoading } = useInventoryActivity(inventoryId);
|
const { data, isLoading } = useQuery(
|
||||||
|
api.warehouses.activity.queryOptions({
|
||||||
|
input: { id: inventoryId },
|
||||||
|
enabled: Boolean(inventoryId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useStations } from '@/hooks/useStations';
|
|
||||||
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
|
|
||||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||||
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
||||||
|
|
||||||
@@ -48,9 +49,11 @@ const emptyForm = (): FormState => ({
|
|||||||
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
|
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
|
||||||
const isEdit = Boolean(warehouse);
|
const isEdit = Boolean(warehouse);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const createMutation = useCreateWarehouse();
|
const createMutation = useMutation(api.warehouses.create.mutationOptions());
|
||||||
const updateMutation = useUpdateWarehouse();
|
const updateMutation = useMutation(api.warehouses.update.mutationOptions());
|
||||||
const { data: stations } = useStations();
|
const { data: stations } = useQuery(
|
||||||
|
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||||
|
);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm());
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
|
||||||
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
|
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses';
|
|
||||||
import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse';
|
import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse';
|
||||||
import { extractErrorMessage, statusOptions, yardTypeOptions } from './options';
|
import { extractErrorMessage, statusOptions, yardTypeOptions } from './options';
|
||||||
|
|
||||||
@@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({
|
|||||||
export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) {
|
export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) {
|
||||||
const isEdit = Boolean(yard);
|
const isEdit = Boolean(yard);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const createMutation = useCreateYard();
|
const createMutation = useMutation(api.warehouses.createYard.mutationOptions());
|
||||||
const updateMutation = useUpdateYard();
|
const updateMutation = useMutation(api.warehouses.updateYard.mutationOptions());
|
||||||
const [form, setForm] = useState<FormState>(emptyForm());
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses';
|
|
||||||
import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse';
|
import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse';
|
||||||
import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options';
|
import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options';
|
||||||
|
|
||||||
@@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({
|
|||||||
export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) {
|
export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) {
|
||||||
const isEdit = Boolean(zone);
|
const isEdit = Boolean(zone);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const createMutation = useCreateZone();
|
const createMutation = useMutation(api.warehouses.createZone.mutationOptions());
|
||||||
const updateMutation = useUpdateZone();
|
const updateMutation = useMutation(api.warehouses.updateZone.mutationOptions());
|
||||||
const [form, setForm] = useState<FormState>(emptyForm());
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useDeliverInventory } from '@/hooks/useWarehouses';
|
|
||||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ interface DeliverInventoryModalProps {
|
|||||||
|
|
||||||
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
|
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const deliverMutation = useDeliverInventory();
|
const deliverMutation = useMutation(api.warehouses.deliver.mutationOptions());
|
||||||
const [receiverName, setReceiverName] = useState('');
|
const [receiverName, setReceiverName] = useState('');
|
||||||
const [remarks, setRemarks] = useState('');
|
const [remarks, setRemarks] = useState('');
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user