Restore Africa navigation graph
Build and deploy Docker image / build (push) Successful in 1m18s

This commit is contained in:
Ruslan Bakiev
2026-08-20 17:23:31 +07:00
parent e0cd782ec5
commit 32f7e480e3
7 changed files with 1964 additions and 3 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ COPY --from=runtime-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY scripts ./scripts
COPY data ./data
EXPOSE 8000
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && node dist/index.js"]
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && 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
@@ -6,7 +6,8 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx --watch src/index.ts",
"seed:demo": "tsx scripts/seed-demo.ts"
"seed:demo": "tsx scripts/seed-demo.ts",
"seed:reference": "tsx scripts/seed-reference.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+241
View File
@@ -0,0 +1,241 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { ensureGraph, getDb } from '../dist/db.js'
type SupplierSeed = { lei: string; name: string; countryCode: string; city: string }
type HubSeed = {
code: string
name: string
countryCode: string
latitude: number
longitude: number
functionCode: string
subdivision: 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 suppliers = JSON.parse(
readFileSync(new URL('../data/africa_suppliers.json', import.meta.url), 'utf8'),
) as SupplierSeed[]
for (const supplier of suppliers) {
if (!supplier.lei || !supplier.name || supplier.countryCode.length !== 2) {
throw new Error(`Invalid Africa supplier reference: ${JSON.stringify(supplier)}`)
}
}
return suppliers
}
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, latitude, longitude, functionCode, subdivision] = parseCsvLine(line)
const parsedLatitude = Number(latitude)
const parsedLongitude = Number(longitude)
if (!code || !name || countryCode.length !== 2 || !Number.isFinite(parsedLatitude) || !Number.isFinite(parsedLongitude)) {
throw new Error(`Invalid UN/LOCODE row: ${line}`)
}
return { code, name, countryCode, latitude: parsedLatitude, longitude: parsedLongitude, functionCode, subdivision }
})
}
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 transportTypes(functionCode: string): string[] {
const values: string[] = []
if (functionCode.includes('1')) values.push('sea')
if (functionCode.includes('2')) values.push('rail')
if (functionCode.includes('3')) values.push('auto')
if (values.length === 0) throw new Error(`Unsupported UN/LOCODE function: ${functionCode}`)
return 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]
}
async function upsertDocuments(collection: string, documents: Record<string, unknown>[]) {
const batchSize = 250
for (let offset = 0; offset < documents.length; offset += batchSize) {
await getDb().query(
`FOR document IN @documents
UPSERT { _key: document._key }
INSERT document
UPDATE document
IN @@collection`,
{ documents: documents.slice(offset, offset + batchSize), '@collection': collection },
)
}
}
await ensureGraph()
const timestamp = new Date().toISOString()
const suppliers = readSuppliers()
const hubs = readHubs()
const products = readProducts()
const supplierHubs = new Map(suppliers.map((supplier) => [supplier.lei, supplierHub(supplier, hubs)]))
const priorityHubCodes = new Set([...supplierHubs.values()].map((hub) => hub.code))
for (const hub of hubs) {
if (!hubs.some((candidate) => candidate.countryCode === hub.countryCode && priorityHubCodes.has(candidate.code))) {
priorityHubCodes.add(hub.code)
}
}
const hubNodes = hubs.map((hub) => {
const country = countryNames.of(hub.countryCode)
if (country === undefined) throw new Error(`Unknown country code: ${hub.countryCode}`)
return {
_key: `africa-${hub.code.toLowerCase()}`,
node_type: 'logistics',
name: hub.name,
latitude: hub.latitude,
longitude: hub.longitude,
country,
country_code: hub.countryCode,
transport_types: transportTypes(hub.functionCode),
navigation_priority: priorityHubCodes.has(hub.code) ? 1 : 0,
source: 'UN/LOCODE Africa',
locode: hub.code,
function_code: hub.functionCode,
subdivision: hub.subdivision,
synced_at: timestamp,
created_at: timestamp,
}
})
const supplierNodes = suppliers.map((supplier, index) => {
const hub = supplierHubs.get(supplier.lei)
if (hub === undefined) throw new Error(`Supplier hub is missing: ${supplier.lei}`)
const country = countryNames.of(supplier.countryCode)
if (country === undefined) throw new Error(`Unknown country code: ${supplier.countryCode}`)
return {
_key: stableUuid('africa-supplier', supplier.lei),
node_type: 'supplier',
name: supplier.name,
latitude: hub.latitude + ((index % 5) - 2) * 0.012,
longitude: hub.longitude + ((Math.floor(index / 5) % 5) - 2) * 0.012,
country,
country_code: supplier.countryCode,
supplier_logo_url: `https://ui-avatars.com/api/?name=${encodeURIComponent(supplier.name)}&background=0B6BFF&color=fff&bold=true`,
lei: supplier.lei,
city: supplier.city,
synced_at: timestamp,
created_at: timestamp,
}
})
const offerNodes: Record<string, unknown>[] = []
const offerEdges: Record<string, unknown>[] = []
for (const [supplierIndex, supplier] of suppliers.entries()) {
const hub = supplierHubs.get(supplier.lei)
if (hub === undefined) throw new Error(`Supplier hub is missing: ${supplier.lei}`)
const supplierNode = supplierNodes[supplierIndex]
for (let productOffset = 0; productOffset < 2; productOffset += 1) {
const product = products[(supplierIndex * 2 + productOffset) % products.length]
const productIndex = products.indexOf(product)
const quoteUuid = stableUuid('africa-quote', `${supplier.lei}:${product.name}`)
const offer = {
_key: quoteUuid,
node_type: 'offer',
name: `${supplier.name}${product.name}`,
latitude: Number(supplierNode.latitude) + (productOffset === 0 ? -0.006 : 0.006),
longitude: Number(supplierNode.longitude) + (productOffset === 0 ? 0.006 : -0.006),
country: supplierNode.country,
country_code: supplier.countryCode,
product_uuid: stableUuid('africa-product', product.name),
product_name: product.name,
product_image_url: productImages[productIndex % productImages.length],
supplier_uuid: supplierNode._key,
supplier_name: supplier.name,
supplier_logo_url: supplierNode.supplier_logo_url,
price_per_unit: String(700 + (supplierIndex % 10) * 65 + productOffset * 40),
currency: 'USD',
quantity: String(250 + (supplierIndex % 12) * 50 + productOffset * 25),
unit: 'ton',
synced_at: timestamp,
created_at: timestamp,
}
offerNodes.push(offer)
offerEdges.push({
_key: `africa-offer-${quoteUuid}`,
_from: `nodes/africa-${hub.code.toLowerCase()}`,
_to: `nodes/${quoteUuid}`,
to_uuid: quoteUuid,
to_name: offer.name,
to_latitude: offer.latitude,
to_longitude: offer.longitude,
distance_km: 2 + productOffset,
travel_time_seconds: 900 + productOffset * 300,
transport_type: 'offer',
created_at: timestamp,
synced_at: timestamp,
})
}
}
await upsertDocuments('nodes', [...hubNodes, ...supplierNodes, ...offerNodes])
await upsertDocuments('edges', offerEdges)
console.log(`Seeded Africa geo reference data: hubs ${hubNodes.length}, suppliers ${supplierNodes.length}, offers ${offerNodes.length}, edges ${offerEdges.length}`)
+1 -1
View File
@@ -1096,7 +1096,7 @@ export const resolvers = {
FILTER @transport_type == null OR @transport_type IN types
FILTER @country == null OR node.country == @country
${bounds.filter}
SORT node.name ASC
SORT node.navigation_priority DESC, node.name ASC
LIMIT @offset, @limit
RETURN node
`, { transport_type: args.transportType ?? null, country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars })