188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
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()
|