diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts index 738609579..60a2c1896 100644 --- a/apps/edr-freight-api/src/scripts/cmds/index.ts +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -1,7 +1,8 @@ import type Vorpal from "vorpal"; import type { CommandContext } from "./types"; -export function registerCommands(_vorpal: Vorpal, _ctx: CommandContext): void { - // Add more command registrations here: - // registerMyNewCommand(vorpal, ctx); +import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; + +export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { + registerSeedTestContracts(vorpal, ctx); } diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts new file mode 100644 index 000000000..17ba79182 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts @@ -0,0 +1,358 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity"; +import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity"; +import { Rate } from "../../modules/rule-engine/entities/rate.entity"; +import { Contract } from "../../modules/contracts/entities/contract.entity"; +import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity"; +import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; +import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; + +const COMPANY_IDS = [ + "a0000001-0000-4000-8000-000000000001", + "a0000001-0000-4000-8000-000000000002", + "a0000001-0000-4000-8000-000000000003", + "a0000001-0000-4000-8000-000000000004", +]; + +const PROFILE_IDS = [ + "b0000001-0000-4000-8000-000000000001", + "b0000001-0000-4000-8000-000000000002", + "b0000001-0000-4000-8000-000000000003", + "b0000001-0000-4000-8000-000000000004", + "b0000001-0000-4000-8000-000000000005", + "b0000001-0000-4000-8000-000000000006", + "b0000001-0000-4000-8000-000000000007", + "b0000001-0000-4000-8000-000000000008", +]; + +const EXTERNAL_PROFILE_IDS = [ + "c0000001-0000-4000-8000-000000000001", + "c0000001-0000-4000-8000-000000000002", + "c0000001-0000-4000-8000-000000000003", + "c0000001-0000-4000-8000-000000000004", +]; + +function generateRef(index: number): string { + return `TST-CTR-${String(index).padStart(5, "0")}`; +} + +export function registerSeedTestContracts( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-contracts", "Generate test contracts with companies and all deps") + .option("-n, --count ", "Number of contracts to create (default: 4)") + .option("--status ", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)") + .option("--freight ", "Freight types: CONTAINER,BULK (default: both)") + .option("--direction ", "Trade directions: IMPORT,EXPORT (default: both)") + .option("--company ", "Only create contracts for company matching name/TIN") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10))); + const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE") + .split(",").map((s: string) => s.trim()).filter(Boolean); + const freightList = (args.options?.freight ?? "CONTAINER,BULK") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "CONTAINER" || s === "BULK"); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const companyFilter = args.options?.company as string | undefined; + + if (freightList.length === 0 || directionList.length === 0) { + this.log("error: at least one freight type and trade direction required"); + return; + } + + this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (!djibouti || !addis) { + this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded"); + return; + } + + const serviceTypes = await ds + .getRepository(ServiceType) + .find({ where: { isActive: true } }); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + if (!railContainer && !railBulk) { + this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded"); + return; + } + + const cargoTypes = await ds + .getRepository(CargoType) + .find({ where: { isActive: true } }); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + const grain = cargoByCode.get("GRAIN"); + const sugar = cargoByCode.get("SUGAR"); + const fertilizer = cargoByCode.get("FERTILIZER"); + + const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } }); + + const companyRepo = ds.getRepository(Company); + let companies = await companyRepo.find({}); + + if (companyFilter) { + companies = companies.filter( + (c) => + c.name.toLowerCase().includes(companyFilter.toLowerCase()) || + c.tin.includes(companyFilter), + ); + } + + if (companies.length === 0) { + this.log("No existing companies found — seeding test companies..."); + companies = await seedTestCompanies(ds, (msg) => this.log(msg)); + } else { + this.log(`Using ${companies.length} existing companies from DB`); + } + + const contractRepo = ds.getRepository(Contract); + let contractCount = 0; + + for (let i = 0; i < count; i++) { + const statusIdx = i % statusList.length; + const ftIdx = i % freightList.length; + const dirIdx = i % directionList.length; + const companyIdx = i % companies.length; + + const status = statusList[statusIdx]; + const freightType = freightList[ftIdx]; + const direction = directionList[dirIdx]; + const company = companies[companyIdx]; + + const profile = await ds.getRepository(CompanyProfile).findOne({ + where: { + companyId: company.id, + type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter, + }, + }); + if (!profile) continue; + + const ref = generateRef(i + 1); + const exists = await contractRepo.findOne({ where: { reference: ref } }); + if (exists) { + this.log(` Contract ${ref} already exists — skipping`); + contractCount++; + continue; + } + + const serviceTypeId = + freightType === "BULK" && railBulk + ? railBulk.id + : railContainer + ? railContainer.id + : serviceTypes[0].id; + + const originId = direction === "IMPORT" ? djibouti.id : addis.id; + const destId = direction === "IMPORT" ? addis.id : djibouti.id; + + const contract = contractRepo.create({ + reference: ref, + companyId: company.id, + companyProfileId: profile.id, + contractKind: "ONE_TIME" as const, + tradeDirection: direction, + freightType, + serviceTypeId, + paymentCurrency: "USD", + customsClearingEnabled: false, + equipmentReturn: "without_return", + status, + versionNumber: 1, + }); + + const saved = await contractRepo.save(contract); + + await ds.getRepository(ContractRoute).save( + ds.getRepository(ContractRoute).create({ + contractId: saved.id, + originYardId: originId, + destinationYardId: destId, + sortOrder: 1, + }), + ); + + if (freightType === "CONTAINER") { + for (const size of ["20FT", "40FT"] as const) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + containerSize: size, + }), + ); + } + } else { + const bulkCargo = grain || sugar || fertilizer; + if (bulkCargo) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + cargoTypeId: bulkCargo.id, + quantityCap: 10000, + }), + ); + } + } + + const matchingRates = rates.filter((r) => { + if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false; + if (r.appliesTo === "BULK" && freightType !== "BULK") return false; + if (r.tradeDirection && r.tradeDirection !== direction) return false; + return r.status === "LIVE" && r.trigger === "ALWAYS"; + }); + + const seen = new Set(); + for (const rate of matchingRates.slice(0, 3)) { + const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`; + if (seen.has(sig)) continue; + seen.add(sig); + + await ds.getRepository(ContractRateSnapshot).save( + ds.getRepository(ContractRateSnapshot).create({ + contractId: saved.id, + rateId: rate.id, + rateCode: rate.rateType, + unitPrice: Number(rate.rateValue), + unitOfMeasure: rate.rateUnit, + currency: rate.currency ?? "USD", + containerSize: freightType === "CONTAINER" ? "20FT" : null, + isSurcharge: rate.trigger !== "ALWAYS", + conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null, + }), + ); + } + + contractCount++; + this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); + } + + this.log(`Done — ${contractCount} contracts ensured`); + }); +} + +interface CompanySeed { + id: string; + name: string; + tin: string; + profiles: Array<{ id: string; type: ProfileType; reference: string }>; + externalProfile: { id: string; userId: string; firstName: string; lastName: string }; +} + +const TEST_COMPANIES: CompanySeed[] = [ + { + id: COMPANY_IDS[0], name: "Test Importer Co.", tin: "TST000001", + profiles: [ + { id: PROFILE_IDS[0], type: ProfileType.importer, reference: "TST-IM-001" }, + { id: PROFILE_IDS[1], type: ProfileType.exporter, reference: "TST-EX-001" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[0], userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, + }, + { + id: COMPANY_IDS[1], name: "Test Exporter Ltd.", tin: "TST000002", + profiles: [ + { id: PROFILE_IDS[2], type: ProfileType.importer, reference: "TST-IM-002" }, + { id: PROFILE_IDS[3], type: ProfileType.exporter, reference: "TST-EX-002" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[1], userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, + }, + { + id: COMPANY_IDS[2], name: "Bulk Commodities PLC", tin: "TST000003", + profiles: [ + { id: PROFILE_IDS[4], type: ProfileType.importer, reference: "TST-IM-003" }, + { id: PROFILE_IDS[5], type: ProfileType.exporter, reference: "TST-EX-003" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[2], userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, + }, + { + id: COMPANY_IDS[3], name: "Hazardous Logistics Inc.", tin: "TST000004", + profiles: [ + { id: PROFILE_IDS[6], type: ProfileType.importer, reference: "TST-IM-004" }, + { id: PROFILE_IDS[7], type: ProfileType.exporter, reference: "TST-EX-004" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[3], userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, + }, +]; + +async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise { + const companyRepo = ds.getRepository(Company); + const profileRepo = ds.getRepository(CompanyProfile); + const extProfileRepo = ds.getRepository(ExternalProfile); + const result: Company[] = []; + + for (const seed of TEST_COMPANIES) { + let company = await companyRepo.findOne({ where: { id: seed.id } }); + if (!company) { + company = await companyRepo.save( + companyRepo.create({ + id: seed.id, + name: seed.name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: seed.tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`, + phone: "+251911000001", + }), + ); + log(` Created company: ${seed.name}`); + } else { + log(` Company already exists: ${seed.name}`); + } + + for (const p of seed.profiles) { + const existing = await profileRepo.findOne({ where: { id: p.id } }); + if (!existing) { + await profileRepo.save( + profileRepo.create({ + id: p.id, + companyId: seed.id, + type: p.type, + reference: p.reference, + status: ProfileStatus.Active, + }), + ); + log(` Created ${p.type} profile: ${p.reference}`); + } + } + + const ext = seed.externalProfile; + const existingExt = await extProfileRepo.findOne({ where: { id: ext.id } }); + if (!existingExt) { + await extProfileRepo.save( + extProfileRepo.create({ + id: ext.id, + userId: ext.userId, + companyId: seed.id, + firstName: ext.firstName, + lastName: ext.lastName, + isPrimaryContact: true, + onboardingCompleted: true, + }), + ); + log(` Created external profile: ${ext.firstName} ${ext.lastName}`); + } + + result.push(company); + } + + return result; +} diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts index 7090f80fc..94b26347f 100644 --- a/apps/edr-freight-api/src/scripts/main.ts +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -18,7 +18,13 @@ async function main() { try { registerCommands(vorpal, { app }); - vorpal.parse(process.argv); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } } finally { await app.close(); }