Compare commits
4
Commits
639e43621f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da13c895d | ||
|
|
f2c2f7f99f | ||
|
|
32f7e480e3 | ||
|
|
e0cd782ec5 |
+14
-16
@@ -1,6 +1,7 @@
|
||||
name: Build and deploy Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -12,24 +13,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Isolate Docker auth
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/docker-config"
|
||||
echo "DOCKER_CONFIG=$RUNNER_TEMP/docker-config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.dsrptlab.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
docker image rm -f "$IMAGE:latest" "$IMAGE:${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy to Dokploy
|
||||
run: curl -fsS -X POST "https://ind.dsrptlab.com/api/deploy/1CoHKHRVGNQ-QYRUNXc-F"
|
||||
- name: Remove local image tags
|
||||
run: docker image rm -f "$IMAGE:latest" "$IMAGE:${{ gitea.sha }}"
|
||||
|
||||
- name: Deploy in Dokploy
|
||||
env:
|
||||
DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$DOKPLOY_DEPLOY_WEBHOOK"
|
||||
curl -fsS -X POST "$DOKPLOY_DEPLOY_WEBHOOK"
|
||||
|
||||
|
||||
+2
-1
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}`)
|
||||
@@ -6,16 +6,30 @@ const ARANGODB_PASSWORD = process.env.ARANGODB_PASSWORD || ''
|
||||
|
||||
let _db: Database | null = null
|
||||
|
||||
function database(databaseName: string): Database {
|
||||
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}`
|
||||
return new Database({ url, databaseName, auth: { username: 'root', password: ARANGODB_PASSWORD } })
|
||||
}
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}`
|
||||
_db = new Database({ url, databaseName: ARANGODB_DATABASE, auth: { username: 'root', password: ARANGODB_PASSWORD } })
|
||||
console.log(`Connected to ArangoDB: ${url}/${ARANGODB_DATABASE}`)
|
||||
_db = database(ARANGODB_DATABASE)
|
||||
console.log(`Connected to ArangoDB: ${ARANGODB_URL}/${ARANGODB_DATABASE}`)
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
async function ensureDatabase(): Promise<void> {
|
||||
const system = database('_system')
|
||||
const databases = await system.listDatabases()
|
||||
if (!databases.includes(ARANGODB_DATABASE)) {
|
||||
await system.createDatabase(ARANGODB_DATABASE)
|
||||
console.log(`Created ArangoDB database: ${ARANGODB_DATABASE}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureGraph(): Promise<void> {
|
||||
await ensureDatabase()
|
||||
const db = getDb()
|
||||
|
||||
const nodes = db.collection('nodes')
|
||||
|
||||
+1
-1
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user