Restore Africa supplier and product catalog
Build and deploy Docker image / build (push) Successful in 4m0s

This commit is contained in:
Ruslan Bakiev
2026-08-20 17:23:31 +07:00
parent 60831b9f8c
commit 5ab16917a1
6 changed files with 1909 additions and 2 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ COPY --from=builder /app/dist ./dist
COPY prisma.config.ts ./ COPY prisma.config.ts ./
COPY prisma ./prisma COPY prisma ./prisma
COPY scripts ./scripts COPY scripts ./scripts
COPY data ./data
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node dist/index.js"] CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && npm run seed:reference && node dist/index.js"]
+17
View File
@@ -0,0 +1,17 @@
product,hs6,label,note
Cocoa Beans,180100,"Cocoa beans; whole or broken, raw or roasted",
Shea Butter,151590,Fixed vegetable fats and oils; other (often used for shea butter),best-fit; shea butter not explicit in HS text
Cashew Nuts,080131,"Cashew nuts, fresh or dried, in shell",
Cashew Nuts,080132,"Cashew nuts, fresh or dried, shelled",
Palm Oil,151110,"Palm oil and its fractions, crude",
Palm Oil,151190,"Palm oil and its fractions, other than crude",
Coffee Beans,090111,Coffee; not roasted or decaffeinated,
Coffee Beans,090112,"Coffee; decaffeinated, not roasted",
Sesame Seeds,120740,"Sesamum seeds, whether or not broken",
Cotton,520100,Cotton; not carded or combed,
Maize,100590,"Maize (corn), other than seed",
Maize,100510,"Maize (corn), seed",
Sorghum,100700,Grain sorghum,
Natural Rubber,400110,"Natural rubber latex, in primary forms",
Natural Rubber,400122,Technically specified natural rubber (TSNR),
Natural Rubber,400129,"Natural rubber, other than latex/TSNR/smoked sheets",
1 product hs6 label note
2 Cocoa Beans 180100 Cocoa beans; whole or broken, raw or roasted
3 Shea Butter 151590 Fixed vegetable fats and oils; other (often used for shea butter) best-fit; shea butter not explicit in HS text
4 Cashew Nuts 080131 Cashew nuts, fresh or dried, in shell
5 Cashew Nuts 080132 Cashew nuts, fresh or dried, shelled
6 Palm Oil 151110 Palm oil and its fractions, crude
7 Palm Oil 151190 Palm oil and its fractions, other than crude
8 Coffee Beans 090111 Coffee; not roasted or decaffeinated
9 Coffee Beans 090112 Coffee; decaffeinated, not roasted
10 Sesame Seeds 120740 Sesamum seeds, whether or not broken
11 Cotton 520100 Cotton; not carded or combed
12 Maize 100590 Maize (corn), other than seed
13 Maize 100510 Maize (corn), seed
14 Sorghum 100700 Grain sorghum
15 Natural Rubber 400110 Natural rubber latex, in primary forms
16 Natural Rubber 400122 Technically specified natural rubber (TSNR)
17 Natural Rubber 400129 Natural rubber, other than latex/TSNR/smoked sheets
+320
View File
@@ -0,0 +1,320 @@
[
{
"lei": "8945006MNLG7IEZZP361",
"name": "TCHIN LAIT",
"countryCode": "DZ",
"city": "BEJAIA"
},
{
"lei": "894500MUZEAR4S9VUF19",
"name": "Fundo de Pensões da ANPG",
"countryCode": "AO",
"city": "Luanda"
},
{
"lei": "254900FAFYBRYHYVQK50",
"name": "BENIN SUKUK",
"countryCode": "BJ",
"city": "Cotonou"
},
{
"lei": "254900AMO5ZG20K1RC67",
"name": "The Genoril Family Foundation Trust",
"countryCode": "BW",
"city": "Gaborone"
},
{
"lei": "2549006CD5SKSN5P3R39",
"name": "Coris Holding",
"countryCode": "BF",
"city": "Ouagadougou"
},
{
"lei": "2138005FD9MF6EWMPQ38",
"name": "FINBANK S.A",
"countryCode": "BI",
"city": "BUJUMBURA"
},
{
"lei": "894500K8QR1WMIRPTI48",
"name": "Banco Interatlântico, SA",
"countryCode": "CV",
"city": "Santiago"
},
{
"lei": "2549004XTX9UX53WO914",
"name": "KAPPA PAY CAMEROON S.A",
"countryCode": "CM",
"city": "Douala"
},
{
"lei": "213800EV35S633NZGJ73",
"name": "INTERIM IMPEX LTD",
"countryCode": "CF",
"city": "BELIZE CITY"
},
{
"lei": "254900CLP4QJ5K9IQZ60",
"name": "FONDATION SAINT LUC",
"countryCode": "TD",
"city": "N'Djamena"
},
{
"lei": "9845007F980D99B5B843",
"name": "Office de radio et télévision des Comores",
"countryCode": "KM",
"city": "Hahaia"
},
{
"lei": "254900QFQHP2H1A18H13",
"name": "SOFIBANQUE SA",
"countryCode": "CD",
"city": "Kinshasa"
},
{
"lei": "894500L731TNWUALQL85",
"name": "Suisse Capital Bank",
"countryCode": "CG",
"city": "kinshasa"
},
{
"lei": "2549002ZE6ZX3NJ24F25",
"name": "NSIA BANQUE COTE D'IVOIRE",
"countryCode": "CI",
"city": "ABIDJAN"
},
{
"lei": "213800AQZACUB1GRIO16",
"name": "INTERNATIONAL INVESTMENT BANK (DJIBOUTI) SA",
"countryCode": "DJ",
"city": "DJIBOUTI"
},
{
"lei": "894500LXVN8OVSBU5Z07",
"name": "ORASCOM DEVELOPMENT EGYPT S.A.E.",
"countryCode": "EG",
"city": "Cairo"
},
{
"lei": "8945006B93MEG50VOY83",
"name": "NOMEX SA",
"countryCode": "GQ",
"city": "MALABO"
},
{
"lei": "213800QPN8GIV3HE7Y16",
"name": "OLD MUTUAL LIFE ASSURANCE COMPANY (ESWATINI) LIMITED",
"countryCode": "SZ",
"city": "MBABANE"
},
{
"lei": "254900ODL6T3024W7T26",
"name": "Sahal Microfinance Institution S.C.",
"countryCode": "ET",
"city": "Jigjiga"
},
{
"lei": "8945008IV1TVEZK9ZE10",
"name": "BATI INVEST DES TRAVAUX PUBLICS",
"countryCode": "GA",
"city": "LIBREVILLE"
},
{
"lei": "254900UML6VPSEXUL552",
"name": "Sunu Pesa Company Ltd",
"countryCode": "GM",
"city": "BANJUL"
},
{
"lei": "894500A44URWYLDKDZ48",
"name": "Quality Insurance PLC",
"countryCode": "GH",
"city": "Accra"
},
{
"lei": "254900EGRWJ18XJTG823",
"name": "Diama Bank SA",
"countryCode": "GN",
"city": "Conakry"
},
{
"lei": "5493000AUJHJ637VBW34",
"name": "MYA GB SA",
"countryCode": "GW",
"city": "BISSAU"
},
{
"lei": "254900HMCSYV1XUOLH30",
"name": "GULF AFRICAN BANK LIMITED",
"countryCode": "KE",
"city": "NAIROBI"
},
{
"lei": "549300OSGF21ECPL5U94",
"name": "PUBLIC OFFICERS' DEFINED CONTRIBUTION PENSION FUND",
"countryCode": "LS",
"city": "MASERU"
},
{
"lei": "213800KIDQUEU39CA834",
"name": "DRAKE II INVESTMENT CORP.",
"countryCode": "LR",
"city": "MONROVIA"
},
{
"lei": "254900SW9BNEN3IW0606",
"name": "Alsanam International Company for the Import of Transport Means and Their Accessories LLC",
"countryCode": "LY",
"city": "Tripoli"
},
{
"lei": "254900R1V61IGBAHBH58",
"name": "ACEP Madagascar S.A",
"countryCode": "MG",
"city": "Antananarivo"
},
{
"lei": "254900M75MKKXRG8UJ24",
"name": "General Alliance Insruance Limited",
"countryCode": "MW",
"city": "Blantyre"
},
{
"lei": "2549004OGQDW7Z0YJK76",
"name": "APS SARL",
"countryCode": "ML",
"city": "Bamako"
},
{
"lei": "2549003R3WIEWCSWMQ96",
"name": "CLEAR ECOLO CLEANER-SARL",
"countryCode": "MR",
"city": "Nouakchott"
},
{
"lei": "984500UF141X3980DB32",
"name": "Schroders Capital Private Equity Asia Mauritius X Limited",
"countryCode": "MU",
"city": "TRIANON"
},
{
"lei": "254900EXH5CAH7F1M005",
"name": "Amazon Data Services Morocco S.A.R.L.",
"countryCode": "MA",
"city": "Rabat"
},
{
"lei": "254900LEOSX6Y17RPE51",
"name": "ACCESS BANK MOZAMBIQUE, SA",
"countryCode": "MZ",
"city": "Maputo"
},
{
"lei": "254900G8YMA6ZH4G8R09",
"name": "Namibia Power Corporation (Proprietary) Limited",
"countryCode": "NA",
"city": "Windhoek"
},
{
"lei": "2549004LW4WH2ME3K171",
"name": "MDT SECURITE",
"countryCode": "NE",
"city": "Niamey"
},
{
"lei": "984500AF636E5F6B5D95",
"name": "CHIRON INDUSTRIES INTERNATIONAL LIMITED",
"countryCode": "NG",
"city": "Ikeja"
},
{
"lei": "254900ZPUOPS6SOW2639",
"name": "AMAZON DATA SERVICES RWANDA LIMITED",
"countryCode": "RW",
"city": "Kigali"
},
{
"lei": "254900BV6GST7KYEKK92",
"name": "GTI Bank Sarl",
"countryCode": "ST",
"city": "AGUA GRANDE"
},
{
"lei": "9845000AF5YF96CAD954",
"name": "PHOENIX SENEGAL",
"countryCode": "SN",
"city": "DAKAR"
},
{
"lei": "213800T7QXLMXOPGNH26",
"name": "ALLIANCE MARKETING GROUP INC.",
"countryCode": "SC",
"city": "MAHE"
},
{
"lei": "254900S70K55I24R6A40",
"name": "BLOOM BANK AFRICA SIERRA LEONE LIMITED",
"countryCode": "SL",
"city": "FREETOWN"
},
{
"lei": "213800JAG5A5QL1PA391",
"name": "SOMALIA REPUBLIC WIDOWS AND ORPHANS PENSION FUND",
"countryCode": "SO",
"city": "MOGADISHU"
},
{
"lei": "213800UE2YQO9NQDF884",
"name": "TEREBINTH TOTAL RETURN FUND EN COMMANDITE PARTNERSHIP",
"countryCode": "ZA",
"city": "BELLVILLE"
},
{
"lei": "894500TQ3YLYPWW51276",
"name": "Charter One Bank",
"countryCode": "SS",
"city": "Juba"
},
{
"lei": "254900MWS2H21N14TX74",
"name": "مكتب الإيثار للإستخدام الخارجي",
"countryCode": "SD",
"city": "Khartoum"
},
{
"lei": "9845003EADEAAB51B135",
"name": "FLAMINGO COMPANY LIMITED",
"countryCode": "TZ",
"city": "Kinondoni"
},
{
"lei": "254900R6Y1CBT4SMXO83",
"name": "ODS - CONSEIL",
"countryCode": "TG",
"city": "Lome"
},
{
"lei": "4117KJVBXU8AY69N0912",
"name": "Delta Express Line",
"countryCode": "TN",
"city": "Rades"
},
{
"lei": "984500864K9557746371",
"name": "Brewfinity Uganda Limited",
"countryCode": "UG",
"city": "KAMPALA"
},
{
"lei": "9845000A7DCXBD946O23",
"name": "VISAKA MINING AND MINERALS ZAMBIA LIMITED",
"countryCode": "ZM",
"city": "LUSAKA"
},
{
"lei": "254900X407BUVRO26U77",
"name": "Delta Gold Zimbabwe (Private) Limited",
"countryCode": "ZW",
"city": "Harare"
}
]
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc", "build": "prisma generate && tsc",
"start": "prisma migrate deploy && node dist/index.js", "start": "prisma migrate deploy && node dist/index.js",
"seed:demo": "tsx scripts/seed-demo.ts" "seed:demo": "tsx scripts/seed-demo.ts",
"seed:reference": "tsx scripts/seed-reference.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+187
View File
@@ -0,0 +1,187 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { Prisma } from '@prisma/client'
import { prisma } from '../dist/db.js'
type SupplierSeed = {
lei: string
name: string
countryCode: string
city: string
}
type HubSeed = {
code: string
name: string
countryCode: string
}
type ProductSeed = {
name: string
hsCodes: string[]
label: string
}
const countryNames = new Intl.DisplayNames(['en'], { type: 'region' })
const productImages = [
'https://images.unsplash.com/photo-1625246333195-78d9c38ad449?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1574323347407-f5e1ad6d020b?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1592924357228-91a4daadcfea?auto=format&fit=crop&w=900&q=80',
]
function parseCsvLine(line: string): string[] {
const fields: string[] = []
let field = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const character = line[index]
if (character === '"') {
if (quoted && line[index + 1] === '"') {
field += '"'
index += 1
} else {
quoted = !quoted
}
} else if (character === ',' && !quoted) {
fields.push(field)
field = ''
} else {
field += character
}
}
if (quoted) throw new Error(`Unclosed CSV quote: ${line}`)
fields.push(field)
return fields
}
function stableUuid(namespace: string, value: string): string {
const hex = createHash('sha256').update(`${namespace}:${value}`).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`
}
function normalize(value: string): string {
return value.normalize('NFKD').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()
}
function readSuppliers(): SupplierSeed[] {
const value = JSON.parse(
readFileSync(new URL('../data/africa_suppliers.json', import.meta.url), 'utf8'),
) as SupplierSeed[]
for (const supplier of value) {
if (!supplier.lei || !supplier.name || supplier.countryCode.length !== 2) {
throw new Error(`Invalid Africa supplier reference: ${JSON.stringify(supplier)}`)
}
}
return value
}
function readHubs(): HubSeed[] {
const csv = readFileSync(
new URL('../data/unlocode_africa_road_terminals.csv', import.meta.url),
'utf8',
)
return csv.trim().split(/\r?\n/).slice(1).map((line) => {
const [code, name, countryCode] = parseCsvLine(line)
if (!code || !name || countryCode.length !== 2) throw new Error(`Invalid hub reference: ${line}`)
return { code, name, countryCode }
})
}
function readProducts(): ProductSeed[] {
const csv = readFileSync(new URL('../data/africa_products.csv', import.meta.url), 'utf8')
const grouped = new Map<string, ProductSeed>()
for (const line of csv.trim().split(/\r?\n/).slice(1)) {
const [name, hsCode, label] = parseCsvLine(line)
if (!name || !/^\d{6}$/.test(hsCode) || !label) throw new Error(`Invalid product reference: ${line}`)
const current = grouped.get(name)
if (current === undefined) grouped.set(name, { name, hsCodes: [hsCode], label })
else current.hsCodes.push(hsCode)
}
return [...grouped.values()]
}
function supplierHub(supplier: SupplierSeed, hubs: HubSeed[]): HubSeed {
const countryHubs = hubs.filter((hub) => hub.countryCode === supplier.countryCode)
if (countryHubs.length === 0) throw new Error(`No Africa hub for ${supplier.countryCode}`)
const city = normalize(supplier.city)
const cityHub = city.length > 0
? countryHubs.find((hub) => normalize(hub.name).includes(city) || city.includes(normalize(hub.name)))
: undefined
return cityHub ?? countryHubs[0]
}
const suppliers = readSuppliers()
const hubs = readHubs()
const products = readProducts()
for (const [index, product] of products.entries()) {
const uuid = stableUuid('africa-product', product.name)
const data = {
sku: `HS-${product.hsCodes[0]}`,
name: product.name,
categoryName: 'African commodities',
unit: 'ton',
imageUrl: productImages[index % productImages.length],
description: `${product.label}. HS codes: ${product.hsCodes.join(', ')}.`,
isActive: true,
}
await prisma.product.upsert({ where: { uuid }, create: { uuid, ...data }, update: data })
}
for (const supplier of suppliers) {
const uuid = stableUuid('africa-supplier', supplier.lei)
const country = countryNames.of(supplier.countryCode)
if (country === undefined) throw new Error(`Unknown country code: ${supplier.countryCode}`)
const data = {
teamUuid: null,
name: supplier.name,
description: `African supplier reference from GLEIF (${supplier.lei}), ${supplier.city}.`,
country,
countryCode: supplier.countryCode,
logoUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(supplier.name)}&background=0B6BFF&color=fff&bold=true`,
isVerified: false,
isActive: true,
}
await prisma.supplier.upsert({ where: { uuid }, create: { uuid, ...data }, update: data })
}
const storedProducts = await prisma.product.findMany({
where: { uuid: { in: products.map((product) => stableUuid('africa-product', product.name)) } },
orderBy: { name: 'asc' },
})
const storedSuppliers = await prisma.supplier.findMany({
where: { uuid: { in: suppliers.map((supplier) => stableUuid('africa-supplier', supplier.lei)) } },
orderBy: { name: 'asc' },
})
let quoteCount = 0
for (const [supplierIndex, supplier] of suppliers.entries()) {
const storedSupplier = storedSuppliers.find((item) => item.uuid === stableUuid('africa-supplier', supplier.lei))
if (storedSupplier === undefined) throw new Error(`Seeded supplier is missing: ${supplier.lei}`)
const hub = supplierHub(supplier, hubs)
for (let productOffset = 0; productOffset < 2; productOffset += 1) {
const productSeed = products[(supplierIndex * 2 + productOffset) % products.length]
const storedProduct = storedProducts.find((item) => item.uuid === stableUuid('africa-product', productSeed.name))
if (storedProduct === undefined) throw new Error(`Seeded product is missing: ${productSeed.name}`)
const uuid = stableUuid('africa-quote', `${supplier.lei}:${productSeed.name}`)
const data = {
supplierId: storedSupplier.id,
productId: storedProduct.id,
status: 'active',
quantity: new Prisma.Decimal(250 + (supplierIndex % 12) * 50 + productOffset * 25),
unit: storedProduct.unit,
pricePerUnit: new Prisma.Decimal(700 + (supplierIndex % 10) * 65 + productOffset * 40),
currency: 'USD',
incotermsCode: productOffset === 0 ? 'FOB' : 'DAP',
originPointUuid: `africa-${hub.code.toLowerCase()}`,
originName: hub.name,
validUntil: new Date('2028-12-31'),
notes: 'Africa reference catalog',
}
await prisma.quote.upsert({ where: { uuid }, create: { uuid, ...data }, update: data })
quoteCount += 1
}
}
console.log(`Seeded Africa exchange reference data: suppliers ${suppliers.length}, products ${products.length}, quotes ${quoteCount}`)
await prisma.$disconnect()