mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
feat: add government support to the companies. add seeding and constrain on booking
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||||
|
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
|||||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
|
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
@@ -139,6 +140,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
DemoFreightDataSeeder,
|
DemoFreightDataSeeder,
|
||||||
|
GovCompaniesSeeder,
|
||||||
IndodeFacilitySeeder,
|
IndodeFacilitySeeder,
|
||||||
Batch14TestDataSeeder,
|
Batch14TestDataSeeder,
|
||||||
Batch5TestDataSeeder,
|
Batch5TestDataSeeder,
|
||||||
@@ -163,6 +165,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
|
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -187,5 +190,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||||
await this.demoFreightDataSeeder.run();
|
await this.demoFreightDataSeeder.run();
|
||||||
|
// Government entities (with importer/exporter profiles) that government
|
||||||
|
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||||
|
await this.govCompaniesSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Government bookings now bill to a real seeded government company + an explicit
|
||||||
|
* importer/exporter profile, instead of carrying a null company + free-text
|
||||||
|
* institution. This migration:
|
||||||
|
*
|
||||||
|
* 1. Adds `companies.kind` (commercial | government).
|
||||||
|
* 2. Seeds the Ethiopian government entities + their importer/exporter
|
||||||
|
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
|
||||||
|
* 3. Backfills every booking with a NULL company_id / company_profile_id so
|
||||||
|
* the NOT NULL constraints below can be applied:
|
||||||
|
* - NULL company_id → the default government company.
|
||||||
|
* - NULL company_profile_id → the company's profile matching the booking
|
||||||
|
* trade direction; else any profile of the company; else the default
|
||||||
|
* government importer profile.
|
||||||
|
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
|
||||||
|
*/
|
||||||
|
export class AddCompanyKindAndGovBookingLinks1821000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddCompanyKindAndGovBookingLinks1821000000003";
|
||||||
|
|
||||||
|
// Mirrors src/seed/data/gov-companies.data.ts
|
||||||
|
private readonly govCompanies = [
|
||||||
|
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
|
||||||
|
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
|
||||||
|
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
|
||||||
|
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
|
||||||
|
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
|
||||||
|
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
|
||||||
|
];
|
||||||
|
|
||||||
|
private get defaultCompanyId(): string {
|
||||||
|
return this.govCompanies[0].id;
|
||||||
|
}
|
||||||
|
private get defaultImporterProfileId(): string {
|
||||||
|
return this.govCompanies[0].im;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 1. kind column
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. seed government companies + importer/exporter profiles (idempotent)
|
||||||
|
for (const g of this.govCompanies) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
|
||||||
|
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.id, g.name, g.tin, g.email, g.phone],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
|
||||||
|
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.im, g.id, g.imRef, g.ex, g.exRef],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a. backfill NULL company_id → default government company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
|
||||||
|
[this.defaultCompanyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3b. backfill NULL company_profile_id → profile matching trade direction
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = cp."id"
|
||||||
|
FROM "freight"."company_profiles" cp
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND cp."company_id" = b."company_id"
|
||||||
|
AND cp."deleted_at" IS NULL
|
||||||
|
AND cp."type" = CASE b."trade_direction"
|
||||||
|
WHEN 'IMPORT' THEN 'importer'
|
||||||
|
WHEN 'EXPORT' THEN 'exporter'
|
||||||
|
ELSE NULL END`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3c. fallback → any profile of the booking's company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = (
|
||||||
|
SELECT cp."id" FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
|
||||||
|
ORDER BY cp."created_at" ASC LIMIT 1)
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3d. final fallback → default government importer profile
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
|
||||||
|
[this.defaultImporterProfileId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. enforce NOT NULL
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
|
||||||
|
);
|
||||||
|
// Seeded government rows are intentionally left in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,8 +312,9 @@ export class BookingOrdersService {
|
|||||||
|
|
||||||
const child = manager.create(Booking, {
|
const child = manager.create(Booking, {
|
||||||
reference,
|
reference,
|
||||||
companyId: contract.companyId ?? null,
|
// Drawdown orders inherit the contract's company + profile (both required).
|
||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyId: contract.companyId,
|
||||||
|
companyProfileId: contract.companyProfileId,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
contractType: contract.contractType,
|
contractType: contract.contractType,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.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';
|
||||||
@@ -299,10 +299,23 @@ export class BookingsService {
|
|||||||
|
|
||||||
let companyId: string | null | undefined = dto.companyId;
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
if (isGovernment) {
|
if (isGovernment) {
|
||||||
if (!dto.governmentInstitution?.trim()) {
|
// Government bookings bill to a real seeded government company + an
|
||||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
// explicitly-chosen importer/exporter profile (no more null company +
|
||||||
|
// free-text institution).
|
||||||
|
if (!dto.companyId) {
|
||||||
|
throw new BadRequestException('A government company is required for government bookings');
|
||||||
}
|
}
|
||||||
companyId = dto.companyId ?? null;
|
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||||
|
if (govCompany.kind !== CompanyKind.Government) {
|
||||||
|
throw new BadRequestException('Selected company is not a government entity');
|
||||||
|
}
|
||||||
|
if (govCompany.status !== CompanyStatus.Active) {
|
||||||
|
throw new BadRequestException('Selected government company is not active');
|
||||||
|
}
|
||||||
|
if (!dto.companyProfileId) {
|
||||||
|
throw new BadRequestException('A government company profile is required for government bookings');
|
||||||
|
}
|
||||||
|
companyId = govCompany.id;
|
||||||
} else if (!companyId) {
|
} else if (!companyId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -375,7 +388,16 @@ export class BookingsService {
|
|||||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
// 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.
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (dto.companyProfileId && companyId) {
|
||||||
|
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||||
|
// must belong to the chosen company and be active.
|
||||||
|
const profile =
|
||||||
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
|
companyId,
|
||||||
|
dto.companyProfileId,
|
||||||
|
);
|
||||||
|
companyProfileId = profile.id;
|
||||||
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
let fallbackType: ProfileType | null = null;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
@@ -405,6 +427,16 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every booking must link to a company and a company profile.
|
||||||
|
if (!companyId) {
|
||||||
|
throw new BadRequestException('A company is required to create a booking');
|
||||||
|
}
|
||||||
|
if (!companyProfileId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A company profile is required to create a booking — none could be resolved for this company',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const needsConsolidation =
|
const needsConsolidation =
|
||||||
dto.freightType === 'CONTAINER'
|
dto.freightType === 'CONTAINER'
|
||||||
? await this.needsConsolidation(containers)
|
? await this.needsConsolidation(containers)
|
||||||
@@ -435,10 +467,10 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId,
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
trainScheduleId: dto.trainScheduleId ?? null,
|
trainScheduleId: dto.trainScheduleId ?? null,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
|
||||||
Validate,
|
Validate,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
/** @deprecated Government bookings now bill to a real government company. */
|
||||||
@ValidateIf((o) => o.isGovernment === true)
|
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
governmentInstitution?: string;
|
governmentInstitution?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
@ApiPropertyOptional({
|
||||||
@ValidateIf((o) => o.isGovernment !== true)
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
// Every booking is billed to a company — government bookings bill to a seeded
|
||||||
companyId?: string | null;
|
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Company, { nullable: true })
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
@@ -116,11 +118,12 @@ export class Booking extends BaseEntity {
|
|||||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||||
* legacy/government/staff-created bookings.
|
* commercial bookings resolve it from trade direction / active mode;
|
||||||
|
* government bookings carry the explicitly-picked government profile.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||||
companyProfileId?: string | null;
|
companyProfileId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_profile_id' })
|
@JoinColumn({ name: 'company_profile_id' })
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
async findPaginated(
|
async findPaginated(
|
||||||
query: ListCompaniesQueryDto,
|
query: ListCompaniesQueryDto,
|
||||||
): Promise<{ items: Company[]; total: number }> {
|
): Promise<{ items: Company[]; total: number }> {
|
||||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('company')
|
.createQueryBuilder('company')
|
||||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
qb.andWhere('company.type = :type', { type });
|
qb.andWhere('company.type = :type', { type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind) {
|
||||||
|
qb.andWhere('company.kind = :kind', { kind });
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
qb.andWhere('company.status = :status', { status });
|
qb.andWhere('company.status = :status', { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
|||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||||
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
|
* pick the profile) and any staff booking that pins a profile directly.
|
||||||
|
*/
|
||||||
|
async getActiveCompanyProfileForBooking(
|
||||||
|
companyId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||||
|
if (!profile || profile.companyId !== companyId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile does not belong to the chosen company",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile is not active",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
async getCompanyInfoByUserId(
|
async getCompanyInfoByUserId(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||||
import { Transform } from "class-transformer";
|
import { Transform } from "class-transformer";
|
||||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||||
|
|
||||||
export class ListCompaniesQueryDto {
|
export class ListCompaniesQueryDto {
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
|||||||
@IsIn(Object.values(CompanyType))
|
@IsIn(Object.values(CompanyType))
|
||||||
type?: CompanyType;
|
type?: CompanyType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanyKind })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(CompanyKind))
|
||||||
|
kind?: CompanyKind;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(Object.values(CompanyStatus))
|
@IsIn(Object.values(CompanyStatus))
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
|||||||
Transporter = "transporter",
|
Transporter = "transporter",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||||
|
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||||
|
* of carrying a null company + free-text institution.
|
||||||
|
*/
|
||||||
|
export enum CompanyKind {
|
||||||
|
Commercial = "commercial",
|
||||||
|
Government = "government",
|
||||||
|
}
|
||||||
|
|
||||||
export enum CompanyStatus {
|
export enum CompanyStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
|||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
|
@Index(["kind"])
|
||||||
export class Company extends BaseEntity {
|
export class Company extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||||
type!: CompanyType;
|
type!: CompanyType;
|
||||||
|
|
||||||
|
/** Commercial customer vs. the seeded government entity. */
|
||||||
|
@Column({
|
||||||
|
name: "kind",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
default: CompanyKind.Commercial,
|
||||||
|
enum: CompanyKind,
|
||||||
|
})
|
||||||
|
kind!: CompanyKind;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
|
|||||||
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, "../../.env") });
|
||||||
|
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { AppModule } from "../app.module";
|
||||||
|
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ["error", "warn", "log"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(GovCompaniesSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log("Government companies seeded.");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Government companies seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
CompanyKind,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
} from "../../modules/companies/entities/company.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "../../modules/companies/entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical list of seeded Ethiopian government entities. Government bookings
|
||||||
|
* are billed to one of these (with an explicit importer/exporter profile)
|
||||||
|
* instead of carrying a null company + free-text institution.
|
||||||
|
*
|
||||||
|
* IDs are fixed so the seeder is idempotent and the matching migration
|
||||||
|
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
|
||||||
|
* the same companies. The migration mirrors these rows in raw SQL — keep both
|
||||||
|
* in sync when adding new entities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GOV_COMPANY_TYPE = CompanyType.Customer;
|
||||||
|
export const GOV_COMPANY_KIND = CompanyKind.Government;
|
||||||
|
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
|
||||||
|
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
|
||||||
|
|
||||||
|
export interface GovProfileSeed {
|
||||||
|
id: string;
|
||||||
|
type: ProfileType;
|
||||||
|
reference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GovCompanySeed {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tin: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
profiles: GovProfileSeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const importExport = (
|
||||||
|
index: number,
|
||||||
|
importerId: string,
|
||||||
|
exporterId: string,
|
||||||
|
): GovProfileSeed[] => [
|
||||||
|
{
|
||||||
|
id: importerId,
|
||||||
|
type: ProfileType.importer,
|
||||||
|
reference: `IM-9000${index}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: exporterId,
|
||||||
|
type: ProfileType.exporter,
|
||||||
|
reference: `EX-9000${index}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GOV_COMPANIES: GovCompanySeed[] = [
|
||||||
|
{
|
||||||
|
id: "0a1b0001-0000-4000-8000-000000000001",
|
||||||
|
name: "Federal Government of Ethiopia",
|
||||||
|
tin: "0000000001",
|
||||||
|
email: "procurement@gov.et",
|
||||||
|
phone: "+251111000001",
|
||||||
|
profiles: importExport(
|
||||||
|
1,
|
||||||
|
"0b1c0001-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0001-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0002-0000-4000-8000-000000000002",
|
||||||
|
name: "Ministry of National Defense",
|
||||||
|
tin: "0000000002",
|
||||||
|
email: "logistics@mod.gov.et",
|
||||||
|
phone: "+251111000002",
|
||||||
|
profiles: importExport(
|
||||||
|
2,
|
||||||
|
"0b1c0002-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0002-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0003-0000-4000-8000-000000000003",
|
||||||
|
name: "Ethiopian Roads Administration",
|
||||||
|
tin: "0000000003",
|
||||||
|
email: "supply@era.gov.et",
|
||||||
|
phone: "+251111000003",
|
||||||
|
profiles: importExport(
|
||||||
|
3,
|
||||||
|
"0b1c0003-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0003-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0004-0000-4000-8000-000000000004",
|
||||||
|
name: "Ministry of Agriculture",
|
||||||
|
tin: "0000000004",
|
||||||
|
email: "imports@moa.gov.et",
|
||||||
|
phone: "+251111000004",
|
||||||
|
profiles: importExport(
|
||||||
|
4,
|
||||||
|
"0b1c0004-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0004-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0005-0000-4000-8000-000000000005",
|
||||||
|
name: "Ministry of Trade and Regional Integration",
|
||||||
|
tin: "0000000005",
|
||||||
|
email: "trade@motri.gov.et",
|
||||||
|
phone: "+251111000005",
|
||||||
|
profiles: importExport(
|
||||||
|
5,
|
||||||
|
"0b1c0005-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0005-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0006-0000-4000-8000-000000000006",
|
||||||
|
name: "Ethiopian Disaster Risk Management Commission",
|
||||||
|
tin: "0000000006",
|
||||||
|
email: "relief@edrmc.gov.et",
|
||||||
|
phone: "+251111000006",
|
||||||
|
profiles: importExport(
|
||||||
|
6,
|
||||||
|
"0b1c0006-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0006-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Fallback entity used to backfill legacy government / null-company bookings. */
|
||||||
|
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
|
||||||
|
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];
|
||||||
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Company } from "../modules/companies/entities/company.entity";
|
||||||
|
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
|
||||||
|
import {
|
||||||
|
GOV_COMPANIES,
|
||||||
|
GOV_COMPANY_KIND,
|
||||||
|
GOV_COMPANY_STATUS,
|
||||||
|
GOV_COMPANY_TYPE,
|
||||||
|
GOV_PROFILE_STATUS,
|
||||||
|
} from "./data/gov-companies.data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently seeds the Ethiopian government entities (with importer + exporter
|
||||||
|
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
|
||||||
|
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GovCompaniesSeeder {
|
||||||
|
private readonly logger = new Logger(GovCompaniesSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const companyRepo = manager.getRepository(Company);
|
||||||
|
const profileRepo = manager.getRepository(CompanyProfile);
|
||||||
|
|
||||||
|
for (const gov of GOV_COMPANIES) {
|
||||||
|
const existing = await companyRepo.findOne({ where: { id: gov.id } });
|
||||||
|
if (!existing) {
|
||||||
|
await companyRepo.save(
|
||||||
|
companyRepo.create({
|
||||||
|
id: gov.id,
|
||||||
|
name: gov.name,
|
||||||
|
type: GOV_COMPANY_TYPE,
|
||||||
|
kind: GOV_COMPANY_KIND,
|
||||||
|
status: GOV_COMPANY_STATUS,
|
||||||
|
tin: gov.tin,
|
||||||
|
country: "Ethiopia",
|
||||||
|
email: gov.email,
|
||||||
|
phone: gov.phone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created government company: ${gov.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of gov.profiles) {
|
||||||
|
const existingProfile = await profileRepo.findOne({
|
||||||
|
where: { id: profile.id },
|
||||||
|
});
|
||||||
|
if (existingProfile) continue;
|
||||||
|
await profileRepo.save(
|
||||||
|
profileRepo.create({
|
||||||
|
id: profile.id,
|
||||||
|
companyId: gov.id,
|
||||||
|
type: profile.type,
|
||||||
|
reference: profile.reference,
|
||||||
|
status: GOV_PROFILE_STATUS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Government companies seeded.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user