Compare commits

..
Author SHA1 Message Date
Ruslan Bakiev d223845310 Add catalog display currency conversion
Build and deploy Docker image / build (push) Successful in 2m2s
2026-08-24 16:48:23 +07:00
Ruslan Bakiev 02c6df0d02 Add product detail price summaries
Build and deploy Docker image / build (push) Successful in 2m35s
2026-08-24 14:20:22 +07:00
Ruslan Bakiev 5ab16917a1 Restore Africa supplier and product catalog
Build and deploy Docker image / build (push) Successful in 4m0s
2026-08-20 17:23:31 +07:00
Ruslan Bakiev 60831b9f8c ci: deploy through Earth Dokploy
Build and deploy Docker image / build (push) Successful in 2m7s
2026-08-19 18:24:25 +07:00
Ruslan Bakiev 19d9a05211 Seed exchange catalog images
Build and deploy Docker image / build (push) Successful in 3m42s
2026-06-11 15:33:52 +07:00
Ruslan Bakiev 2e3baf7634 Align exchange demo multimodal catalog
Build and deploy Docker image / build (push) Successful in 2m20s
2026-06-08 07:30:36 +07:00
Ruslan Bakiev 37bd31524f Align demo exchange seed graph
Build and deploy Docker image / build (push) Successful in 2m43s
2026-06-07 14:09:24 +07:00
Ruslan Bakiev 5c52a98ba8 Fix demo seed runtime
Build and deploy Docker image / build (push) Successful in 3m53s
2026-06-07 11:09:55 +07:00
Ruslan Bakiev 821b9c0a03 Align backend dependencies
Build and deploy Docker image / build (push) Successful in 2m20s
2026-06-07 10:52:19 +07:00
Ruslan Bakiev ed94ae514a Add demo seed command
Build and deploy Docker image / build (push) Successful in 2m45s
2026-06-07 10:43:12 +07:00
Ruslan Bakiev 9d5b9d7eab Isolate Docker auth in CI
Build and deploy Docker image / build (push) Successful in 2m28s
2026-06-05 13:19:06 +07:00
Ruslan Bakiev bf83727e5a Clean local Docker images after CI push
Build and deploy Docker image / build (push) Successful in 2m32s
2026-06-05 12:51:33 +07:00
Ruslan Bakiev 71576fb65b Include Prisma config in runtime image
Build and deploy Docker image / build (push) Successful in 36s
2026-06-02 08:22:00 +07:00
Ruslan Bakiev f4c1f4ebfc Configure builder registry deployment
Build and deploy Docker image / build (push) Successful in 3m19s
2026-06-01 17:28:05 +05:00
Ruslan Bakiev aff1c63b44 Use Fastify Mercurius and GraphQL contracts
Build Docker Image / build (push) Has been cancelled
2026-06-01 00:25:27 +05:00
Ruslan Bakiev a499e03401 Split exchange into quotes service
Build Docker Image / build (push) Successful in 2m0s
2026-05-31 16:14:18 +05:00
Ruslan Bakiev 5eae00961c chore: optimize docker image build pipeline
Build Docker Image / build (push) Successful in 6m3s
2026-03-14 10:37:46 +07:00
Ruslan Bakiev 43e4cba3b8 fix: correct Dokploy webhook token
Build Docker Image / build (push) Successful in 4m0s
2026-03-10 11:06:12 +07:00
Ruslan Bakiev 09d660585e ci: trigger rebuild
Build Docker Image / build (push) Successful in 4m30s
2026-03-10 10:54:35 +07:00
Ruslan Bakiev 53628ff0d3 fix: update Dokploy webhook URL to dsrptlab.com
Build Docker Image / build (push) Successful in 3m17s
2026-03-10 10:48:19 +07:00
Ruslan Bakiev de5b9ba025 Fix set -e conflict with prisma migrate resolve
Build Docker Image / build (push) Failing after 4m19s
2026-03-09 11:49:57 +07:00
Ruslan Bakiev 0883a4ead8 Switch from Infisical to Vault for secret loading
Build Docker Image / build (push) Failing after 4m11s
2026-03-09 11:20:25 +07:00
Ruslan Bakiev 052979965d Trigger CI rebuild with baseline migration
Build Docker Image / build (push) Successful in 3m30s
2026-03-09 10:21:55 +07:00
Ruslan Bakiev bb5a202ec5 Add baseline migration and fix prisma deploy
Build Docker Image / build (push) Has been cancelled
2026-03-09 10:18:33 +07:00
Ruslan Bakiev 3169f09672 Add Infisical secret loading at startup
Build Docker Image / build (push) Successful in 3m18s
2026-03-09 10:00:52 +07:00
Ruslan Bakiev 27b86c85b7 Migrate exchange backend from Django to Express + Apollo Server + Prisma
Build Docker Image / build (push) Successful in 1m54s
Replace Python/Django/Graphene with TypeScript/Express/Apollo Server.
Same 4 endpoints (public/user/team/m2m), same JWT auth.
Prisma replaces Django ORM for Offer/Request/SupplierProfile.
Temporal and Odoo integrations preserved.
2026-03-09 09:20:37 +07:00
Ruslan Bakiev 569924cabb Seed from HS CSV and require real data
Build Docker Image / build (push) Successful in 2m58s
2026-02-05 18:42:55 +07:00
122 changed files with 6727 additions and 3911 deletions
+25
View File
@@ -0,0 +1,25 @@
.git
.gitignore
.DS_Store
*.log
node_modules
.pnpm-store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example
__pycache__
*.pyc
*.pyo
.venv
venv
.pytest_cache
.mypy_cache
coverage
.coverage
.nuxt
.output
dist
build
+20 -18
View File
@@ -1,31 +1,33 @@
name: Build Docker Image
name: Build and deploy Docker image
on:
workflow_dispatch:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
runs-on: builder
env:
IMAGE: gitea.dsrptlab.com/optovia/exchange/exchange
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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 }}"
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.dsrptlab.com
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Remove local image tags
run: docker image rm -f "$IMAGE:latest" "$IMAGE:${{ gitea.sha }}"
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: gitea.dsrptlab.com/optovia/exchange/exchange:latest
- 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"
- name: Deploy to Dokploy
run: curl -X POST "https://dokploy.optovia.ru/api/deploy/ifNT1RxaDwz0R0ERyJSjX"
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+3
View File
@@ -0,0 +1,3 @@
[submodule "graphql-contracts"]
path = graphql-contracts
url = git@gitea.dsrptlab.com:optovia/exchange-graphql-contracts.git
+30 -16
View File
@@ -1,24 +1,38 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NIXPACKS_POETRY_VERSION=2.2.1
FROM node:22-alpine AS deps
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
RUN python -m venv --copies /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
FROM deps AS builder
COPY . .
COPY prisma ./prisma
RUN EXCHANGE_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres npx prisma generate
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
&& poetry install --no-interaction --no-ansi
COPY tsconfig.json ./
COPY src ./src
RUN EXCHANGE_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres npm run build
ENV PORT=8000
FROM deps AS runtime-deps
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn exchange.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
FROM node:22-alpine AS runtime
RUN apk add --no-cache curl jq
WORKDIR /app
COPY package.json ./
COPY --from=runtime-deps /app/node_modules ./node_modules
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist
COPY prisma.config.ts ./
COPY prisma ./prisma
COPY scripts ./scripts
COPY data ./data
EXPOSE 8000
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && npm run seed:reference && node dist/index.js"]
+3 -41
View File
@@ -1,43 +1,5 @@
# Exchange Service
# Optovia Exchange
Backend сервис для биржи товаров в системе Optovia.
GraphQL service for the marketplace layer: suppliers, products, and quotes.
## Описание
Сервис для управления офферами (предложениями) и заявками (RFQ) на товары. Включает интеграцию с Odoo для получения справочников товаров и логистических узлов.
## Основные функции
- Создание и управление офферами (каталог товаров)
- Позиции офферов с ценами и количествами
- Создание заявок на товары (RFQ)
- Проксирование справочников из Odoo (товары, локации)
## Модели данных
- **Offer** - предложение товаров от команды
- **OfferLine** - позиции оффера (товар, количество, цена)
- **Request** - заявка на товар (RFQ)
## Статусы офферов
- `draft` - Черновик
- `active` - Активно
- `closed` - Закрыто
- `cancelled` - Отменено
## Технологии
- Django 5.2.8
- GraphQL (Graphene-Django)
- PostgreSQL
- Odoo Integration
- Gunicorn
## Развертывание
Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями.
## Автор
Ruslan Bakiev
This service does not own logistics hubs, route graph data, route pricing, or shipment calculations. Those belong to `backends/logistics`.
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1",
"name": "ExchangeRate-API currency rates",
"type": "collection"
}
+18
View File
@@ -0,0 +1,18 @@
meta {
name: Latest USD rates
type: http
seq: 1
}
get {
url: https://open.er-api.com/v6/latest/USD
body: none
auth: none
}
assert {
res.status: eq 200
res.body.result: eq success
res.body.base_code: eq USD
res.body.rates.USD: eq 1
}
@@ -0,0 +1,18 @@
{
"result": "success",
"provider": "https://www.exchangerate-api.com",
"documentation": "https://www.exchangerate-api.com/docs/free",
"terms_of_use": "https://www.exchangerate-api.com/terms",
"time_last_update_unix": 1787529751,
"time_last_update_utc": "Mon, 24 Aug 2026 00:02:31 +0000",
"time_next_update_unix": 1787617021,
"time_next_update_utc": "Tue, 25 Aug 2026 00:17:01 +0000",
"base_code": "USD",
"rates": {
"USD": 1,
"RUB": 82.802865,
"CNY": 6.729114,
"EUR": 0.85618,
"AED": 3.6725
}
}
+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
View File
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
"""
ASGI config for exchange project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.settings')
application = get_asgi_application()
-66
View File
@@ -1,66 +0,0 @@
import logging
from typing import Iterable, Optional
import jwt
from django.conf import settings
from jwt import InvalidTokenError, PyJWKClient
logger = logging.getLogger(__name__)
class LogtoTokenValidator:
"""Validate JWTs issued by Logto using the published JWKS."""
def __init__(self, jwks_url: str, issuer: str):
self._issuer = issuer
self._jwks_client = PyJWKClient(jwks_url)
def decode(self, token: str, audience: Optional[str] = None) -> dict:
"""Decode and verify a JWT, enforcing issuer and optional audience."""
try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
header_alg = jwt.get_unverified_header(token).get("alg")
return jwt.decode(
token,
signing_key.key,
algorithms=[header_alg] if header_alg else None,
issuer=self._issuer,
audience=audience,
options={"verify_aud": audience is not None},
)
except InvalidTokenError as exc:
logger.warning("Failed to validate Logto token: %s", exc)
raise
def get_bearer_token(request) -> str:
"""Extract Bearer token from Authorization header."""
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header.startswith("Bearer "):
raise InvalidTokenError("Missing Bearer token")
token = auth_header.split(" ", 1)[1]
if not token or token == "undefined":
raise InvalidTokenError("Empty Bearer token")
return token
def scopes_from_payload(payload: dict) -> list[str]:
"""Split scope string (if present) into a list."""
scope_value = payload.get("scope")
if not scope_value:
return []
if isinstance(scope_value, str):
return scope_value.split()
if isinstance(scope_value, Iterable):
return list(scope_value)
return []
validator = LogtoTokenValidator(
getattr(settings, "LOGTO_JWKS_URL", "https://auth.optovia.ru/oidc/jwks"),
getattr(settings, "LOGTO_ISSUER", "https://auth.optovia.ru/oidc"),
)
-74
View File
@@ -1,74 +0,0 @@
"""
GraphQL middleware for JWT authentication.
Each class is bound to a specific GraphQL endpoint (public/user/team/m2m).
"""
from django.conf import settings
from graphql import GraphQLError
from jwt import InvalidTokenError
from .auth import get_bearer_token, scopes_from_payload, validator
def _is_introspection(info) -> bool:
"""Возвращает True для любых introspection резолвов."""
field = getattr(info, "field_name", "")
parent = getattr(getattr(info, "parent_type", None), "name", "")
return field.startswith("__") or parent.startswith("__")
class PublicNoAuthMiddleware:
"""Public endpoint - no authentication required."""
def resolve(self, next, root, info, **kwargs):
return next(root, info, **kwargs)
class UserJWTMiddleware:
"""User endpoint - requires ID token."""
def resolve(self, next, root, info, **kwargs):
request = info.context
if _is_introspection(info):
return next(root, info, **kwargs)
try:
token = get_bearer_token(request)
payload = validator.decode(token)
request.user_id = payload.get('sub')
except InvalidTokenError as exc:
raise GraphQLError("Unauthorized") from exc
return next(root, info, **kwargs)
class TeamJWTMiddleware:
"""Team endpoint - requires Access token for exchange audience."""
def resolve(self, next, root, info, **kwargs):
request = info.context
if _is_introspection(info):
return next(root, info, **kwargs)
try:
token = get_bearer_token(request)
payload = validator.decode(
token,
audience=getattr(settings, 'LOGTO_EXCHANGE_AUDIENCE', None),
)
request.user_id = payload.get('sub')
request.team_uuid = payload.get('team_uuid')
request.scopes = scopes_from_payload(payload)
if not request.team_uuid or 'teams:member' not in request.scopes:
raise GraphQLError("Unauthorized")
except InvalidTokenError as exc:
raise GraphQLError("Unauthorized") from exc
return next(root, info, **kwargs)
class M2MNoAuthMiddleware:
"""M2M endpoint - internal services only, no auth for now."""
def resolve(self, next, root, info, **kwargs):
return next(root, info, **kwargs)
-74
View File
@@ -1,74 +0,0 @@
"""
Декоратор для проверки scopes в JWT токене.
Используется для защиты GraphQL резолверов.
"""
from functools import wraps
from graphql import GraphQLError
def require_scopes(*scopes: str):
"""
Декоратор для проверки наличия scopes в JWT токене.
Использование:
@require_scopes("read:requests")
def resolve_get_requests(self, info):
...
@require_scopes("create:offers")
def mutate(self, info):
...
"""
def decorator(func):
# Сохраняем scopes в метаданных для возможности сбора всех scopes
if not hasattr(func, '_required_scopes'):
func._required_scopes = []
func._required_scopes.extend(scopes)
@wraps(func)
def wrapper(self, info, *args, **kwargs):
# Получаем scopes из контекста (должны быть добавлены в middleware)
user_scopes = set(getattr(info.context, 'scopes', []) or [])
missing = set(scopes) - user_scopes
if missing:
raise GraphQLError(f"Missing required scopes: {', '.join(missing)}")
return func(self, info, *args, **kwargs)
# Переносим метаданные на wrapper
wrapper._required_scopes = func._required_scopes
return wrapper
return decorator
def collect_scopes_from_schema(schema) -> set:
"""
Собирает все scopes из схемы для синхронизации с Logto.
Использование:
from .schema import schema
scopes = collect_scopes_from_schema(schema)
# {'read:requests', 'create:offers', ...}
"""
scopes = set()
# Query resolvers
if hasattr(schema, 'query') and schema.query:
query_type = schema.query
for field_name in dir(query_type):
if field_name.startswith('resolve_'):
resolver = getattr(query_type, field_name, None)
if resolver and hasattr(resolver, '_required_scopes'):
scopes.update(resolver._required_scopes)
# Mutation resolvers
if hasattr(schema, 'mutation') and schema.mutation:
mutation_type = schema.mutation
for field_name, field in mutation_type._meta.fields.items():
if hasattr(field, 'type') and hasattr(field.type, 'mutate'):
mutate = field.type.mutate
if hasattr(mutate, '_required_scopes'):
scopes.update(mutate._required_scopes)
return scopes
View File
-127
View File
@@ -1,127 +0,0 @@
"""
M2M (Machine-to-Machine) GraphQL schema for Exchange.
Used by internal services (Temporal workflows, etc.) without user authentication.
"""
import graphene
import logging
from graphene_django import DjangoObjectType
from offers.models import Offer
logger = logging.getLogger(__name__)
class OfferType(DjangoObjectType):
class Meta:
model = Offer
fields = "__all__"
class M2MQuery(graphene.ObjectType):
offer = graphene.Field(OfferType, offerUuid=graphene.String(required=True))
def resolve_offer(self, info, offerUuid):
try:
return Offer.objects.get(uuid=offerUuid)
except Offer.DoesNotExist:
return None
class CreateOfferFromWorkflowInput(graphene.InputObjectType):
offerUuid = graphene.String(required=True)
teamUuid = graphene.String(required=True)
productUuid = graphene.String(required=True)
productName = graphene.String(required=True)
categoryName = graphene.String()
locationUuid = graphene.String()
locationName = graphene.String()
locationCountry = graphene.String()
locationCountryCode = graphene.String()
locationLatitude = graphene.Float()
locationLongitude = graphene.Float()
quantity = graphene.Decimal(required=True)
unit = graphene.String()
pricePerUnit = graphene.Decimal()
currency = graphene.String()
description = graphene.String()
validUntil = graphene.Date()
terminusSchemaId = graphene.String()
terminusDocumentId = graphene.String()
class CreateOfferFromWorkflow(graphene.Mutation):
class Arguments:
input = CreateOfferFromWorkflowInput(required=True)
success = graphene.Boolean()
message = graphene.String()
offer = graphene.Field(OfferType)
def mutate(self, info, input):
try:
offer = Offer.objects.filter(uuid=input.offerUuid).first()
if offer:
logger.info("Offer %s already exists, returning existing", input.offerUuid)
return CreateOfferFromWorkflow(success=True, message="Offer exists", offer=offer)
offer = Offer.objects.create(
uuid=input.offerUuid,
team_uuid=input.teamUuid,
product_uuid=input.productUuid,
product_name=input.productName,
category_name=input.categoryName or '',
location_uuid=input.locationUuid or '',
location_name=input.locationName or '',
location_country=input.locationCountry or '',
location_country_code=input.locationCountryCode or '',
location_latitude=input.locationLatitude,
location_longitude=input.locationLongitude,
quantity=input.quantity,
unit=input.unit or 'ton',
price_per_unit=input.pricePerUnit,
currency=input.currency or 'USD',
description=input.description or '',
valid_until=input.validUntil,
terminus_schema_id=input.terminusSchemaId or '',
terminus_document_id=input.terminusDocumentId or '',
workflow_status='pending',
)
logger.info("Created offer %s via workflow", offer.uuid)
return CreateOfferFromWorkflow(success=True, message="Offer created", offer=offer)
except Exception as exc:
logger.exception("Failed to create offer %s", input.offerUuid)
return CreateOfferFromWorkflow(success=False, message=str(exc), offer=None)
class UpdateOfferWorkflowStatusInput(graphene.InputObjectType):
offerUuid = graphene.String(required=True)
status = graphene.String(required=True) # pending | active | error
errorMessage = graphene.String()
class UpdateOfferWorkflowStatus(graphene.Mutation):
class Arguments:
input = UpdateOfferWorkflowStatusInput(required=True)
success = graphene.Boolean()
message = graphene.String()
offer = graphene.Field(OfferType)
def mutate(self, info, input):
try:
offer = Offer.objects.get(uuid=input.offerUuid)
offer.workflow_status = input.status
if input.errorMessage is not None:
offer.workflow_error = input.errorMessage
offer.save(update_fields=["workflow_status", "workflow_error", "updated_at"])
logger.info("Offer %s workflow_status updated to %s", input.offerUuid, input.status)
return UpdateOfferWorkflowStatus(success=True, message="Status updated", offer=offer)
except Offer.DoesNotExist:
return UpdateOfferWorkflowStatus(success=False, message="Offer not found", offer=None)
class M2MMutation(graphene.ObjectType):
createOfferFromWorkflow = CreateOfferFromWorkflow.Field()
updateOfferWorkflowStatus = UpdateOfferWorkflowStatus.Field()
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)
-186
View File
@@ -1,186 +0,0 @@
import graphene
from graphene_django import DjangoObjectType
from offers.models import Offer
from suppliers.models import SupplierProfile
from ..services import OdooService
class Product(graphene.ObjectType):
uuid = graphene.String()
name = graphene.String()
category_id = graphene.Int()
category_name = graphene.String()
terminus_schema_id = graphene.String()
class SupplierProfileType(DjangoObjectType):
"""Профиль поставщика на бирже"""
offers_count = graphene.Int()
country_code = graphene.String()
class Meta:
model = SupplierProfile
fields = "__all__"
def resolve_offers_count(self, info):
return Offer.objects.filter(team_uuid=self.team_uuid, status='active').count()
def resolve_country_code(self, info):
return getattr(self, 'country_code', '')
class OfferType(DjangoObjectType):
class Meta:
model = Offer
fields = "__all__"
class PublicQuery(graphene.ObjectType):
"""Public schema - no authentication required"""
get_products = graphene.List(Product)
get_available_products = graphene.List(
Product,
description="Get products that have active offers"
)
get_supplier_profiles = graphene.List(
SupplierProfileType,
country=graphene.String(),
is_verified=graphene.Boolean(),
limit=graphene.Int(),
offset=graphene.Int(),
)
get_supplier_profiles_count = graphene.Int(
country=graphene.String(),
is_verified=graphene.Boolean(),
)
get_supplier_profile = graphene.Field(SupplierProfileType, uuid=graphene.String(required=True))
get_supplier_profile_by_team = graphene.Field(
SupplierProfileType,
team_uuid=graphene.String(required=True),
description="Get supplier profile by team UUID"
)
get_offers = graphene.List(
OfferType,
status=graphene.String(),
product_uuid=graphene.String(),
location_uuid=graphene.String(),
category_name=graphene.String(),
team_uuid=graphene.String(),
limit=graphene.Int(),
offset=graphene.Int(),
)
get_offers_count = graphene.Int(
status=graphene.String(),
product_uuid=graphene.String(),
location_uuid=graphene.String(),
category_name=graphene.String(),
team_uuid=graphene.String(),
)
get_offer = graphene.Field(OfferType, uuid=graphene.String(required=True))
def resolve_get_products(self, info):
odoo_service = OdooService()
products_data = odoo_service.get_products()
return [Product(**product) for product in products_data]
def resolve_get_available_products(self, info):
"""Get only products that have active offers."""
# Get unique product UUIDs from active offers
product_uuids = set(
Offer.objects.filter(status='active')
.values_list('product_uuid', flat=True)
.distinct()
)
if not product_uuids:
return []
# Get all products from Odoo and filter by those with offers
odoo_service = OdooService()
products_data = odoo_service.get_products()
return [
Product(**product)
for product in products_data
if product.get('uuid') in product_uuids
]
@staticmethod
def _get_supplier_profiles_queryset(country=None, is_verified=None):
queryset = SupplierProfile.objects.filter(is_active=True)
if country:
queryset = queryset.filter(country__icontains=country)
if is_verified is not None:
queryset = queryset.filter(is_verified=is_verified)
return queryset
def resolve_get_supplier_profiles(self, info, country=None, is_verified=None, limit=None, offset=None):
queryset = PublicQuery._get_supplier_profiles_queryset(country=country, is_verified=is_verified)
if offset is not None:
queryset = queryset[offset:]
if limit is not None:
queryset = queryset[:limit]
return queryset
def resolve_get_supplier_profiles_count(self, info, country=None, is_verified=None):
return PublicQuery._get_supplier_profiles_queryset(country=country, is_verified=is_verified).count()
def resolve_get_supplier_profile(self, info, uuid):
try:
return SupplierProfile.objects.get(uuid=uuid)
except SupplierProfile.DoesNotExist:
return None
def resolve_get_supplier_profile_by_team(self, info, team_uuid):
try:
return SupplierProfile.objects.get(team_uuid=team_uuid)
except SupplierProfile.DoesNotExist:
return None
@staticmethod
def _get_offers_queryset(status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None):
queryset = Offer.objects.all()
if status:
queryset = queryset.filter(status=status)
else:
queryset = queryset.filter(status='active')
if team_uuid:
queryset = queryset.filter(team_uuid=team_uuid)
if location_uuid:
queryset = queryset.filter(location_uuid=location_uuid)
if product_uuid:
queryset = queryset.filter(product_uuid=product_uuid)
if category_name:
queryset = queryset.filter(category_name__icontains=category_name)
return queryset
def resolve_get_offers(self, info, status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None, limit=None, offset=None):
queryset = PublicQuery._get_offers_queryset(
status=status,
product_uuid=product_uuid,
location_uuid=location_uuid,
category_name=category_name,
team_uuid=team_uuid,
)
if offset is not None:
queryset = queryset[offset:]
if limit is not None:
queryset = queryset[:limit]
return queryset
def resolve_get_offers_count(self, info, status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None):
return PublicQuery._get_offers_queryset(
status=status,
product_uuid=product_uuid,
location_uuid=location_uuid,
category_name=category_name,
team_uuid=team_uuid,
).count()
def resolve_get_offer(self, info, uuid):
try:
return Offer.objects.get(uuid=uuid)
except Offer.DoesNotExist:
return None
public_schema = graphene.Schema(query=PublicQuery)
-198
View File
@@ -1,198 +0,0 @@
import graphene
from graphene_django import DjangoObjectType
from offers.models import Offer
from purchase_requests.models import Request
from ..permissions import require_scopes
import uuid as uuid_lib
class RequestType(DjangoObjectType):
class Meta:
model = Request
fields = "__all__"
class OfferType(DjangoObjectType):
class Meta:
model = Offer
fields = "__all__"
class RequestInput(graphene.InputObjectType):
product_uuid = graphene.String(required=True)
quantity = graphene.Decimal(required=True)
source_location_uuid = graphene.String(required=True)
user_id = graphene.String(required=True)
class OfferInput(graphene.InputObjectType):
team_uuid = graphene.String(required=True)
# Товар
product_uuid = graphene.String(required=True)
product_name = graphene.String(required=True)
category_name = graphene.String()
# Локация
location_uuid = graphene.String()
location_name = graphene.String()
location_country = graphene.String()
location_country_code = graphene.String()
location_latitude = graphene.Float()
location_longitude = graphene.Float()
# Цена и количество
quantity = graphene.Decimal(required=True)
unit = graphene.String()
price_per_unit = graphene.Decimal()
currency = graphene.String()
# Прочее
description = graphene.String()
valid_until = graphene.Date()
terminus_schema_id = graphene.String()
terminus_payload = graphene.JSONString()
class TeamQuery(graphene.ObjectType):
"""Team schema - Team Access Token authentication"""
get_requests = graphene.List(RequestType, user_id=graphene.String(required=True))
get_request = graphene.Field(RequestType, uuid=graphene.String(required=True))
get_team_offers = graphene.List(OfferType, team_uuid=graphene.String(required=True))
@require_scopes("teams:member")
def resolve_get_requests(self, info, user_id):
return Request.objects.filter(user_id=user_id).order_by('-created_at')
@require_scopes("teams:member")
def resolve_get_request(self, info, uuid):
try:
return Request.objects.get(uuid=uuid)
except Request.DoesNotExist:
return None
@require_scopes("teams:member")
def resolve_get_team_offers(self, info, team_uuid):
return Offer.objects.filter(team_uuid=team_uuid).order_by('-created_at')
class CreateRequest(graphene.Mutation):
class Arguments:
input = RequestInput(required=True)
request = graphene.Field(RequestType)
@require_scopes("teams:member")
def mutate(self, info, input):
request = Request(
uuid=str(uuid_lib.uuid4()),
product_uuid=input.product_uuid,
quantity=input.quantity,
source_location_uuid=input.source_location_uuid,
user_id=input.user_id,
)
request.save()
return CreateRequest(request=request)
class CreateOffer(graphene.Mutation):
class Arguments:
input = OfferInput(required=True)
success = graphene.Boolean()
message = graphene.String()
workflowId = graphene.String()
offerUuid = graphene.String()
@require_scopes("teams:member")
def mutate(self, info, input):
from ..temporal_client import start_offer_workflow
offer_uuid = str(uuid_lib.uuid4())
workflow_id, _ = start_offer_workflow(
offer_uuid=offer_uuid,
team_uuid=input.team_uuid,
product_uuid=input.product_uuid,
product_name=input.product_name,
category_name=input.category_name,
location_uuid=input.location_uuid,
location_name=input.location_name,
location_country=input.location_country,
location_country_code=input.location_country_code,
location_latitude=input.location_latitude,
location_longitude=input.location_longitude,
quantity=input.quantity,
unit=input.unit,
price_per_unit=input.price_per_unit,
currency=input.currency,
description=input.description,
valid_until=input.valid_until,
terminus_schema_id=getattr(input, "terminus_schema_id", None),
terminus_payload=getattr(input, "terminus_payload", None),
)
return CreateOffer(
success=True,
message="Offer workflow started",
workflowId=workflow_id,
offerUuid=offer_uuid,
)
class UpdateOffer(graphene.Mutation):
class Arguments:
uuid = graphene.String(required=True)
input = OfferInput(required=True)
offer = graphene.Field(OfferType)
@require_scopes("teams:member")
def mutate(self, info, uuid, input):
try:
offer = Offer.objects.get(uuid=uuid)
except Offer.DoesNotExist:
raise Exception("Offer not found")
# Обновляем поля
offer.product_uuid = input.product_uuid
offer.product_name = input.product_name
offer.category_name = input.category_name or ''
offer.location_uuid = input.location_uuid or ''
offer.location_name = input.location_name or ''
offer.location_country = input.location_country or ''
offer.location_country_code = input.location_country_code or ''
offer.location_latitude = input.location_latitude
offer.location_longitude = input.location_longitude
offer.quantity = input.quantity
offer.unit = input.unit or 'ton'
offer.price_per_unit = input.price_per_unit
offer.currency = input.currency or 'USD'
offer.description = input.description or ''
offer.valid_until = input.valid_until
if input.terminus_schema_id is not None:
offer.terminus_schema_id = input.terminus_schema_id
offer.save()
return UpdateOffer(offer=offer)
class DeleteOffer(graphene.Mutation):
class Arguments:
uuid = graphene.String(required=True)
success = graphene.Boolean()
@require_scopes("teams:member")
def mutate(self, info, uuid):
try:
offer = Offer.objects.get(uuid=uuid)
offer.delete()
return DeleteOffer(success=True)
except Offer.DoesNotExist:
return DeleteOffer(success=False)
class TeamMutation(graphene.ObjectType):
"""Team mutations - Team Access Token authentication"""
create_request = CreateRequest.Field()
create_offer = CreateOffer.Field()
update_offer = UpdateOffer.Field()
delete_offer = DeleteOffer.Field()
team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation)
-12
View File
@@ -1,12 +0,0 @@
import graphene
class UserQuery(graphene.ObjectType):
"""User schema - ID token authentication"""
_placeholder = graphene.String(description="Placeholder field")
def resolve__placeholder(self, info):
return None
user_schema = graphene.Schema(query=UserQuery)
-25
View File
@@ -1,25 +0,0 @@
import requests as http_requests
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class OdooService:
def __init__(self):
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
def get_products(self):
"""Получить список всех товаров из Odoo"""
try:
url = f"{self.base_url}/fastapi/products/products"
response = http_requests.get(url, timeout=10)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Error fetching products: {response.status_code}")
return []
except Exception as e:
logger.error(f"Error fetching products from Odoo: {e}")
return []
-141
View File
@@ -1,141 +0,0 @@
import os
from pathlib import Path
from urllib.parse import urlparse
from infisical_sdk import InfisicalSDKClient
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
INFISICAL_API_URL = os.environ["INFISICAL_API_URL"]
INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"]
INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"]
INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"]
INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod")
client = InfisicalSDKClient(host=INFISICAL_API_URL)
client.auth.universal_auth.login(
client_id=INFISICAL_CLIENT_ID,
client_secret=INFISICAL_CLIENT_SECRET,
)
# Fetch secrets from /exchange and /shared
for secret_path in ["/exchange", "/shared"]:
secrets_response = client.secrets.list_secrets(
environment_slug=INFISICAL_ENV,
secret_path=secret_path,
project_id=INFISICAL_PROJECT_ID,
expand_secret_references=True,
view_secret_value=True,
)
for secret in secrets_response.secrets:
os.environ[secret.secretKey] = secret.secretValue
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = os.getenv('DEBUG', 'False') == 'True'
# Sentry/GlitchTip configuration
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
if SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration()],
auto_session_tracking=False,
traces_sample_rate=0.01,
release=os.getenv('RELEASE_VERSION', '1.0.0'),
environment=os.getenv('ENVIRONMENT', 'production'),
send_default_pii=False,
debug=DEBUG,
)
ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = ['https://exchange.optovia.ru']
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'graphene_django',
'offers',
'purchase_requests',
'suppliers',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'exchange.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'exchange.wsgi.application'
db_url = os.environ["EXCHANGE_DATABASE_URL"]
parsed = urlparse(db_url)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': parsed.path.lstrip('/'),
'USER': parsed.username,
'PASSWORD': parsed.password,
'HOST': parsed.hostname,
'PORT': str(parsed.port) if parsed.port else '',
}
}
# Internationalization
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = ['https://optovia.ru']
CORS_ALLOW_CREDENTIALS = True
# Logto JWT settings
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
LOGTO_EXCHANGE_AUDIENCE = os.getenv('LOGTO_EXCHANGE_AUDIENCE', 'https://exchange.optovia.ru')
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
# Odoo connection (internal M2M)
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')
-110
View File
@@ -1,110 +0,0 @@
import os
from pathlib import Path
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = True
# Sentry/GlitchTip configuration
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
if SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration()],
auto_session_tracking=False,
traces_sample_rate=0.01,
release=os.getenv('RELEASE_VERSION', '1.0.0'),
environment=os.getenv('ENVIRONMENT', 'production'),
send_default_pii=False,
debug=DEBUG,
)
ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = ['https://exchange.optovia.ru']
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'graphene_django',
'offers',
'purchase_requests',
'suppliers',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'exchange.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'exchange.wsgi.application'
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Internationalization
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru']
CORS_ALLOW_CREDENTIALS = True
# Logto JWT settings
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
LOGTO_EXCHANGE_AUDIENCE = os.getenv('LOGTO_EXCHANGE_AUDIENCE', 'https://exchange.optovia.ru')
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
# Odoo connection (internal M2M)
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')
-81
View File
@@ -1,81 +0,0 @@
import asyncio
import logging
import os
from typing import Tuple
from temporalio.client import Client
logger = logging.getLogger(__name__)
TEMPORAL_INTERNAL_URL = os.getenv("TEMPORAL_INTERNAL_URL", "temporal:7233")
TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default")
TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker")
async def _start_offer_workflow_async(payload: dict) -> Tuple[str, str]:
client = await Client.connect(TEMPORAL_INTERNAL_URL, namespace=TEMPORAL_NAMESPACE)
workflow_id = f"offer-{payload['offer_uuid']}"
handle = await client.start_workflow(
"create_offer",
payload,
id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE,
)
logger.info("Started offer workflow %s", workflow_id)
return handle.id, handle.result_run_id
def start_offer_workflow(
*,
offer_uuid: str,
team_uuid: str,
supplier_uuid: str | None = None,
product_uuid: str,
product_name: str,
category_name: str | None = None,
location_uuid: str | None = None,
location_name: str | None = None,
location_country: str | None = None,
location_country_code: str | None = None,
location_latitude: float | None = None,
location_longitude: float | None = None,
quantity=None,
unit: str | None = None,
price_per_unit=None,
currency: str | None = None,
description: str | None = None,
valid_until=None,
terminus_schema_id: str | None = None,
terminus_payload: dict | None = None,
) -> Tuple[str, str]:
payload = {
"offer_uuid": offer_uuid,
"team_uuid": team_uuid,
"supplier_uuid": supplier_uuid,
"product_uuid": product_uuid,
"product_name": product_name,
"category_name": category_name,
"location_uuid": location_uuid,
"location_name": location_name,
"location_country": location_country,
"location_country_code": location_country_code,
"location_latitude": location_latitude,
"location_longitude": location_longitude,
"quantity": str(quantity) if quantity is not None else None,
"unit": unit,
"price_per_unit": str(price_per_unit) if price_per_unit is not None else None,
"currency": currency,
"description": description,
"valid_until": valid_until.isoformat() if hasattr(valid_until, "isoformat") else valid_until,
"terminus_schema_id": terminus_schema_id,
"terminus_payload": terminus_payload,
}
try:
return asyncio.run(_start_offer_workflow_async(payload))
except Exception:
logger.exception("Failed to start offer workflow %s", offer_uuid)
raise
-16
View File
@@ -1,16 +0,0 @@
from django.contrib import admin
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from .views import PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView
from .schemas.public_schema import public_schema
from .schemas.user_schema import user_schema
from .schemas.team_schema import team_schema
from .schemas.m2m_schema import m2m_schema
urlpatterns = [
path('admin/', admin.site.urls),
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))),
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
]
-45
View File
@@ -1,45 +0,0 @@
"""
GraphQL Views for Exchange API.
Authentication is handled by GRAPHENE MIDDLEWARE in settings.py
"""
from graphene_django.views import GraphQLView
from .graphql_middleware import (
M2MNoAuthMiddleware,
PublicNoAuthMiddleware,
TeamJWTMiddleware,
UserJWTMiddleware,
)
class PublicGraphQLView(GraphQLView):
"""Public endpoint - no authentication required."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [PublicNoAuthMiddleware()]
super().__init__(*args, **kwargs)
class UserGraphQLView(GraphQLView):
"""User endpoint - requires ID Token."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [UserJWTMiddleware()]
super().__init__(*args, **kwargs)
class TeamGraphQLView(GraphQLView):
"""Team endpoint - requires Organization Access Token."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [TeamJWTMiddleware()]
super().__init__(*args, **kwargs)
class M2MGraphQLView(GraphQLView):
"""M2M endpoint - internal services only."""
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [M2MNoAuthMiddleware()]
super().__init__(*args, **kwargs)
-11
View File
@@ -1,11 +0,0 @@
"""
WSGI config for exchange project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.settings')
application = get_wsgi_application()
+1
Submodule graphql-contracts added at d22dff8e1f
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
-18
View File
@@ -1,18 +0,0 @@
providers = ["python"]
[build]
[phases.install]
cmds = [
"python -m venv --copies /opt/venv",
". /opt/venv/bin/activate",
"pip install poetry==$NIXPACKS_POETRY_VERSION",
"poetry install --no-interaction --no-ansi"
]
[start]
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn exchange.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
[variables]
# Set Poetry version to match local environment
NIXPACKS_POETRY_VERSION = "2.2.1"
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-55
View File
@@ -1,55 +0,0 @@
from django.contrib import admin, messages
from .models import Offer
from .services import OfferService
@admin.register(Offer)
class OfferAdmin(admin.ModelAdmin):
list_display = [
'product_name',
'status',
'workflow_status',
'team_uuid',
'location_name',
'location_country',
'quantity',
'price_per_unit',
'created_at',
]
list_filter = ['status', 'workflow_status', 'created_at', 'category_name', 'location_country']
search_fields = ['product_name', 'description', 'location_name', 'uuid']
readonly_fields = ['uuid', 'workflow_status', 'workflow_error', 'created_at', 'updated_at']
actions = ['sync_to_graph']
@admin.action(description="Синхронизировать в граф (запустить workflow)")
def sync_to_graph(self, request, queryset):
"""Запускает workflow для пересинхронизации выбранных офферов в ArangoDB граф"""
success_count = 0
error_count = 0
for offer in queryset:
try:
workflow_id, run_id = OfferService.resync_offer_via_workflow(offer)
offer.workflow_status = 'pending'
offer.workflow_error = ''
offer.save(update_fields=['workflow_status', 'workflow_error'])
success_count += 1
except Exception as e:
offer.workflow_status = 'error'
offer.workflow_error = str(e)
offer.save(update_fields=['workflow_status', 'workflow_error'])
error_count += 1
if success_count:
self.message_user(
request,
f"Запущен workflow для {success_count} офферов",
messages.SUCCESS,
)
if error_count:
self.message_user(
request,
f"Ошибка при запуске workflow для {error_count} офферов",
messages.ERROR,
)
-6
View File
@@ -1,6 +0,0 @@
from django.apps import AppConfig
class OffersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'offers'
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-890
View File
@@ -1,890 +0,0 @@
"""
Seed Suppliers and Offers for African cocoa belt.
Creates offers via Temporal workflow so they sync to the graph.
"""
import csv
import os
import random
import uuid
from pathlib import Path
from decimal import Decimal
import time
import requests
from django.core.management.base import BaseCommand
from django.db import transaction
from offers.models import Offer
from offers.services import OfferService, OfferData
from suppliers.models import SupplierProfile
# African cocoa belt countries
AFRICAN_COUNTRIES = [
("Côte d'Ivoire", "CI", 6.8276, -5.2893), # Abidjan
("Ghana", "GH", 5.6037, -0.1870), # Accra
("Nigeria", "NG", 6.5244, 3.3792), # Lagos
("Cameroon", "CM", 4.0511, 9.7679), # Douala
("Togo", "TG", 6.1725, 1.2314), # Lomé
]
# Realistic supplier names (English, Africa-focused)
SUPPLIER_NAMES = [
"Cocoa Coast Exports", "Golden Savannah Trading", "Abidjan Agro Partners",
"Volta River Commodities", "Lagos Harbor Supply", "Accra Prime Exports",
"Tema Logistics & Trading", "Sahel Harvest Group", "Nile Delta Commodities",
"Gulf of Guinea Traders", "Kumasi Cocoa Collective", "Benin AgroLink",
"Douala Growth Partners", "Westbridge Commodities", "Ivory Gate Exporters",
"Ghana Frontier Trading", "Sunrise Agro Holdings", "Coastal Belt Supply",
"Keta Shore Commodities", "Takoradi Export House", "Mango Bay Trading",
"Savanna Crest Exports", "Sankofa Trade Corp", "Niger Delta Agrimark",
"Lake Volta Produce", "Zou River Exports", "Lomé Port Traders",
"Atlantic Harvest Co", "Forest Belt Commodities", "Côte d'Ivoire Supply",
"Ashanti Agro Trade", "Midland Cocoa Group", "Sahelian Produce Traders",
"Kintampo Agro Partners", "Gold Coast Exporters", "Cashew Ridge Trading",
"Prairie Coast Supply", "Harborline Exports", "Palm Coast Commodities",
"Green Belt Trading", "Westland Agro Link", "Delta Coast Produce",
"Kongo River Exports", "Bight of Benin Supply", "Akwa Ibom Traders",
"Cameroon Highlands Trading", "Coastal Plains Export", "Guinea Gulf Trading",
"Korhogo Agro Supply", "Northern Plains Traders", "Oti River Exports",
"Eastern Coast Commodities", "Sunset Bay Exporters", "Freetown Agro Trade",
"Makola Market Supply", "Afram Plains Trading", "Cedar Coast Commodities",
"Monrovia Export House", "Bissau Agro Partners", "Lac Togo Traders",
"Riverine Agro Link", "Cape Coast Exporters", "Delta Rise Commodities",
"Mali Savanna Trade", "Burkina Harvest Co", "Niger Basin Exports",
"Sierra Green Trading", "Liberia Agro Collective", "Congo Gate Traders",
"Ashanti Heritage Exports", "Ivory Belt Trading", "Sahel Horizon Supply",
"Atlantic Crest Commodities", "Green Valley Export", "Cocoa Ridge Trade",
"Palm Grove Exports", "Keta Delta Trading", "Lagoon Coast Commodities",
"Accra Trade Works", "Tema Export Alliance", "Lagos Trade Link",
"Cape Three Points Exports", "Ivory Coast Agro Hub", "Savanna Trade Network",
"Nile Coast Commodities", "Sahara Edge Trading", "Goldleaf Exports",
"Makeni Agro Partners", "Bamako Produce Traders", "Ouagadougou Exports",
"Conakry Trade House", "Port Harcourt Supply", "Calabar Exporters",
"Abuja Agro Traders", "Eko Commodities", "Gabon Forest Trade",
"Libreville Export Group", "Senegal River Commodities", "Dakar Trade Alliance",
"Kaolack Agro Supply", "Saint-Louis Exporters", "Zanzibar Coast Trading",
"Kilwa Harvest Group", "Lake Victoria Exports", "Mombasa Trade Gate",
"Dar Coast Commodities", "Maputo Export House",
]
# Default GLEIF Africa LEI dataset path (repo-local)
DEFAULT_GLEIF_PATH = "datasets/gleif/africa_lei_companies.csv"
# Fixed product catalog (10 items) with realistic prices per ton (USD)
PRODUCT_CATALOG = [
{"name": "Cocoa Beans", "category": "Cocoa", "price": Decimal("2450.00")},
{"name": "Shea Butter", "category": "Oils & Fats", "price": Decimal("1800.00")},
{"name": "Cashew Nuts", "category": "Nuts", "price": Decimal("5200.00")},
{"name": "Palm Oil", "category": "Oils & Fats", "price": Decimal("980.00")},
{"name": "Coffee Beans", "category": "Coffee", "price": Decimal("3800.00")},
{"name": "Sesame Seeds", "category": "Seeds", "price": Decimal("2100.00")},
{"name": "Cotton", "category": "Fiber", "price": Decimal("1650.00")},
{"name": "Maize", "category": "Grains", "price": Decimal("260.00")},
{"name": "Sorghum", "category": "Grains", "price": Decimal("230.00")},
{"name": "Natural Rubber", "category": "Industrial", "price": Decimal("1750.00")},
]
class Command(BaseCommand):
help = "Seed Suppliers and Offers for African cocoa belt with workflow sync"
def add_arguments(self, parser):
parser.add_argument(
"--suppliers",
type=int,
default=10,
help="How many suppliers to create (default: 10)",
)
parser.add_argument(
"--offers",
type=int,
default=50,
help="How many offers to create (default: 50)",
)
parser.add_argument(
"--product-count",
type=int,
default=10,
help="How many distinct products to use (default: 10)",
)
parser.add_argument(
"--supplier-location-ratio",
type=float,
default=0.8,
help="Share of offers that use supplier address (default: 0.8)",
)
parser.add_argument(
"--clear",
action="store_true",
help="Delete all existing suppliers and offers before seeding",
)
parser.add_argument(
"--no-workflow",
action="store_true",
help="Create offers directly in DB without workflow (no graph sync)",
)
parser.add_argument(
"--bulk",
action="store_true",
help="Use bulk_create for offers (only with --no-workflow)",
)
parser.add_argument(
"--bulk-size",
type=int,
default=200,
help="Batch size for bulk_create (default: 200)",
)
parser.add_argument(
"--sleep-ms",
type=int,
default=0,
help="Sleep between offer creations in milliseconds (default: 0)",
)
parser.add_argument(
"--geo-url",
type=str,
default=None,
help="Geo service GraphQL URL (defaults to GEO_INTERNAL_URL env var)",
)
parser.add_argument(
"--odoo-url",
type=str,
default="http://odoo:8069",
help="Odoo URL (default: http://odoo:8069)",
)
parser.add_argument(
"--ensure-products",
action="store_true",
help="Ensure product catalog exists in Odoo (create if missing)",
)
parser.add_argument(
"--odoo-db",
type=str,
default="odoo",
help="Odoo database name (default: odoo)",
)
parser.add_argument(
"--odoo-user",
type=int,
default=2,
help="Odoo user id (default: 2)",
)
parser.add_argument(
"--odoo-password",
type=str,
default="admin",
help="Odoo password (default: admin)",
)
parser.add_argument(
"--product",
type=str,
default=None,
help="Filter offers by product name (e.g., 'Cocoa Beans')",
)
parser.add_argument(
"--company-csv",
type=str,
default=None,
help="Path to CSV with real company names (default: datasets/gleif/africa_lei_companies.csv)",
)
def handle(self, *args, **options):
if options["clear"]:
with transaction.atomic():
offers_deleted, _ = Offer.objects.all().delete()
suppliers_deleted, _ = SupplierProfile.objects.all().delete()
self.stdout.write(self.style.WARNING(
f"Deleted {suppliers_deleted} supplier profiles and {offers_deleted} offers"
))
suppliers_count = max(0, options["suppliers"])
offers_count = max(0, options["offers"])
product_count = max(1, options["product_count"])
supplier_location_ratio = min(max(options["supplier_location_ratio"], 0.0), 1.0)
use_workflow = not options["no_workflow"]
use_bulk = options["bulk"]
bulk_size = max(1, options["bulk_size"])
# Enforce fixed 1s delay to protect infra regardless of CLI flags
sleep_ms = 1000
geo_url = (
options["geo_url"]
or os.getenv("GEO_INTERNAL_URL")
or os.getenv("GEO_EXTERNAL_URL")
or os.getenv("GEO_URL")
)
if not geo_url:
self.stdout.write(self.style.ERROR("Geo URL is not set. Provide --geo-url or GEO_INTERNAL_URL."))
return
geo_url = self._normalize_geo_url(geo_url)
odoo_url = options["odoo_url"]
product_filter = options["product"]
ensure_products = options["ensure_products"]
odoo_db = options["odoo_db"]
odoo_user = options["odoo_user"]
odoo_password = options["odoo_password"]
company_csv = options["company_csv"]
# Fetch products from Odoo
self.stdout.write("Fetching products from Odoo...")
products = self._fetch_products_from_odoo(odoo_url, odoo_db, odoo_user, odoo_password)
if ensure_products:
self.stdout.write("Ensuring product catalog exists in Odoo...")
products = self._ensure_products_in_odoo(
odoo_url, odoo_db, odoo_user, odoo_password, products
)
if not products:
self.stdout.write(self.style.WARNING("No products found in Odoo. Falling back to catalog only."))
products = self._catalog_products()
self.stdout.write(f"Found {len(products)} products")
# Filter by product name if specified
if product_filter:
products = [p for p in products if product_filter.lower() in p[0].lower()]
if not products:
self.stdout.write(self.style.ERROR(f"No products matching '{product_filter}' found."))
return
self.stdout.write(f"Filtered to {len(products)} products matching '{product_filter}'")
# Limit to product_count distinct items (random sample if possible)
if len(products) > product_count:
products = random.sample(products, product_count)
self.stdout.write(f"Using {len(products)} products for seeding")
# Fetch African hubs from geo service
self.stdout.write("Fetching African hubs from geo service...")
hubs = self._fetch_african_hubs(geo_url)
if not hubs:
self.stdout.write(self.style.ERROR("No African hubs found from geo service. Aborting seed."))
return
self.stdout.write(f"Found {len(hubs)} African hubs")
# Create suppliers
self._company_pool = self._load_company_pool(company_csv)
self.stdout.write(f"Creating {suppliers_count} suppliers...")
new_suppliers = self._create_suppliers(suppliers_count, hubs)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_suppliers)} suppliers"))
# Create offers
self.stdout.write(f"Creating {offers_count} offers (workflow={use_workflow})...")
if use_workflow and use_bulk:
self.stdout.write(self.style.ERROR("Bulk mode is only supported with --no-workflow."))
return
if use_workflow:
created_offers = self._create_offers_via_workflow(
offers_count, hubs, products, supplier_location_ratio, sleep_ms
)
elif use_bulk:
created_offers = self._create_offers_direct_bulk(
offers_count, hubs, products, supplier_location_ratio, bulk_size
)
else:
created_offers = self._create_offers_direct(
offers_count, hubs, products, supplier_location_ratio, sleep_ms
)
self.stdout.write(self.style.SUCCESS(f"Created {len(created_offers)} offers"))
def _catalog_products(self) -> list:
return [(p["name"], p["category"], str(uuid.uuid4()), p["price"]) for p in PRODUCT_CATALOG]
def _fetch_products_from_odoo(self, odoo_url: str, odoo_db: str, odoo_user: int, odoo_password: str) -> list:
"""Fetch products from Odoo via JSON-RPC"""
products = []
try:
# Search for products
response = requests.post(
f"{odoo_url}/jsonrpc",
json={
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
odoo_db, # database
odoo_user, # uid
odoo_password, # password
"products.product", # model
"search_read",
[[]], # domain (all products)
{"fields": ["uuid", "name", "category_id"]},
],
},
"id": 1,
},
timeout=10,
)
if response.status_code == 200:
data = response.json()
result = data.get("result", [])
for p in result:
category_name = p.get("category_id", [None, "Agriculture"])[1] if p.get("category_id") else "Agriculture"
price = self._price_for_product(p.get("name", ""))
products.append((p["name"], category_name, p.get("uuid") or str(uuid.uuid4()), price))
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to fetch products from Odoo: {e}"))
return products
def _ensure_products_in_odoo(
self, odoo_url: str, odoo_db: str, odoo_user: int, odoo_password: str, existing: list
) -> list:
"""Ensure PRODUCT_CATALOG exists in Odoo, return unified list."""
existing_names = {p[0] for p in existing}
products = list(existing)
for item in PRODUCT_CATALOG:
if item["name"] in existing_names:
continue
try:
# Find or create category
category_id = self._get_or_create_category(
odoo_url, odoo_db, odoo_user, odoo_password, item["category"]
)
response = requests.post(
f"{odoo_url}/jsonrpc",
json={
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
odoo_db,
odoo_user,
odoo_password,
"products.product",
"create",
[
{
"name": item["name"],
"category_id": category_id,
"uuid": str(uuid.uuid4()),
}
],
],
},
"id": 1,
},
timeout=10,
)
if response.status_code == 200 and response.json().get("result"):
created_uuid = self._fetch_product_uuid(
odoo_url, odoo_db, odoo_user, odoo_password, item["name"]
)
products.append((
item["name"],
item["category"],
created_uuid or str(uuid.uuid4()),
item["price"],
))
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to create product {item['name']}: {e}"))
return products
def _fetch_product_uuid(
self, odoo_url: str, odoo_db: str, odoo_user: int, odoo_password: str, name: str
) -> str | None:
response = requests.post(
f"{odoo_url}/jsonrpc",
json={
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
odoo_db,
odoo_user,
odoo_password,
"products.product",
"search_read",
[[("name", "=", name)]],
{"fields": ["uuid"], "limit": 1},
],
},
"id": 1,
},
timeout=10,
)
if response.status_code == 200:
result = response.json().get("result", [])
if result and result[0].get("uuid"):
return result[0]["uuid"]
return None
def _get_or_create_category(
self, odoo_url: str, odoo_db: str, odoo_user: int, odoo_password: str, name: str
) -> int:
"""Find or create a product category in Odoo."""
response = requests.post(
f"{odoo_url}/jsonrpc",
json={
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
odoo_db,
odoo_user,
odoo_password,
"product.category",
"search",
[[("name", "=", name)]],
{"limit": 1},
],
},
"id": 1,
},
timeout=10,
)
if response.status_code == 200 and response.json().get("result"):
return response.json()["result"][0]
response = requests.post(
f"{odoo_url}/jsonrpc",
json={
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
odoo_db,
odoo_user,
odoo_password,
"product.category",
"create",
[{"name": name}],
],
},
"id": 1,
},
timeout=10,
)
return response.json().get("result", 1)
def _fetch_african_hubs(self, geo_url: str) -> list:
"""Fetch African hubs from geo service via GraphQL.
Gets all nodes and filters by African countries in Python
since the GraphQL schema doesn't support country filter.
"""
african_countries = {
"Côte d'Ivoire", "Ivory Coast", "Ghana", "Nigeria",
"Cameroon", "Togo", "Senegal", "Mali", "Burkina Faso",
"Guinea", "Benin", "Niger", "Sierra Leone", "Liberia",
}
query = """
query GetNodes($limit: Int) {
nodes(limit: $limit) {
uuid
name
country
countryCode
latitude
longitude
}
}
"""
try:
response = requests.post(
geo_url,
json={"query": query, "variables": {"limit": 5000}},
timeout=30,
)
if response.status_code == 200:
data = response.json()
if "errors" in data:
self.stdout.write(self.style.WARNING(f"GraphQL errors: {data['errors']}"))
return []
nodes = data.get("data", {}).get("nodes", [])
# Filter by African countries
african_hubs = [
n for n in nodes
if n.get("country") in african_countries
]
return african_hubs
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to fetch hubs: {e}"))
return []
def _default_african_hubs(self) -> list:
"""Default African hubs if geo service is unavailable"""
return [
{
"uuid": str(uuid.uuid4()),
"name": "Port of Abidjan",
"country": "Côte d'Ivoire",
"countryCode": "CI",
"latitude": 5.3167,
"longitude": -4.0167,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of San Pedro",
"country": "Côte d'Ivoire",
"countryCode": "CI",
"latitude": 4.7500,
"longitude": -6.6333,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of Tema",
"country": "Ghana",
"countryCode": "GH",
"latitude": 5.6333,
"longitude": -0.0167,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of Takoradi",
"country": "Ghana",
"countryCode": "GH",
"latitude": 4.8833,
"longitude": -1.7500,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of Lagos",
"country": "Nigeria",
"countryCode": "NG",
"latitude": 6.4531,
"longitude": 3.3958,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of Douala",
"country": "Cameroon",
"countryCode": "CM",
"latitude": 4.0483,
"longitude": 9.7043,
},
{
"uuid": str(uuid.uuid4()),
"name": "Port of Lomé",
"country": "Togo",
"countryCode": "TG",
"latitude": 6.1375,
"longitude": 1.2125,
},
]
def _create_suppliers(self, count: int, hubs: list) -> list:
"""Create supplier profiles in African countries"""
created = []
for idx in range(count):
hub = random.choice(hubs) if hubs else None
country, country_code = self._get_random_african_country()
# Use hub coordinates if available, otherwise use country defaults
if hub:
lat = hub["latitude"] + random.uniform(-0.5, 0.5)
lng = hub["longitude"] + random.uniform(-0.5, 0.5)
else:
lat, lng = self._get_country_coords(country)
lat += random.uniform(-0.5, 0.5)
lng += random.uniform(-0.5, 0.5)
company = self._pick_company(idx)
if company:
name = company["name"]
company_code = company.get("country_code")
mapped_name = self._country_name_from_code(company_code)
if mapped_name:
country = mapped_name
country_code = company_code
supplier_uuid = self._stable_uuid("supplier", company.get("lei") or name)
team_uuid = self._stable_uuid("team", company.get("lei") or name)
else:
name = self._generate_supplier_name(idx)
supplier_uuid = str(uuid.uuid4())
team_uuid = str(uuid.uuid4())
description = (
f"{name} is a reliable supplier based in {country}, "
"focused on consistent quality and transparent logistics."
)
profile = SupplierProfile.objects.create(
uuid=supplier_uuid,
team_uuid=team_uuid,
name=name,
description=description,
country=country,
country_code=country_code,
logo_url="",
latitude=lat,
longitude=lng,
is_verified=random.choice([True, True, False]), # 66% verified
is_active=True,
)
created.append(profile)
return created
def _generate_supplier_name(self, index: int) -> str:
"""Pick a realistic supplier name; fall back if list is exhausted."""
if index < len(SUPPLIER_NAMES):
return SUPPLIER_NAMES[index]
return f"{random.choice(SUPPLIER_NAMES)} Group"
def _find_default_company_csv(self) -> str | None:
"""Locate default company CSV in repo (datasets/gleif/africa_lei_companies.csv)."""
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / DEFAULT_GLEIF_PATH
if candidate.exists():
return str(candidate)
return None
def _load_company_pool(self, csv_path: str | None) -> list[dict]:
"""Load real company names from CSV; returns list of dicts."""
path = csv_path or self._find_default_company_csv()
if not path or not os.path.exists(path):
self.stdout.write(self.style.WARNING("Company CSV not found; using fallback names."))
return []
companies = []
seen = set()
try:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
name = (row.get("entity_name") or "").strip()
if not name:
continue
if name in seen:
continue
seen.add(name)
companies.append(
{
"name": name,
"lei": (row.get("lei") or "").strip(),
"country_code": (row.get("legal_address_country") or row.get("headquarters_country") or "").strip(),
"city": (row.get("legal_address_city") or row.get("headquarters_city") or "").strip(),
}
)
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to read company CSV: {e}"))
return []
random.shuffle(companies)
self.stdout.write(f"Loaded {len(companies)} company names from CSV")
return companies
def _pick_company(self, index: int) -> dict | None:
if not getattr(self, "_company_pool", None):
return None
if index < len(self._company_pool):
return self._company_pool[index]
return random.choice(self._company_pool)
def _stable_uuid(self, prefix: str, value: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{prefix}:{value}"))
def _country_name_from_code(self, code: str | None) -> str | None:
if not code:
return None
for name, country_code, _, _ in AFRICAN_COUNTRIES:
if country_code == code:
return name
return None
def _normalize_geo_url(self, url: str) -> str:
"""Ensure geo URL has scheme and GraphQL path."""
value = url.strip()
if not value.startswith(("http://", "https://")):
value = f"http://{value}"
if "/graphql" not in value:
value = value.rstrip("/") + "/graphql/public/"
return value
def _price_for_product(self, product_name: str) -> Decimal:
for item in PRODUCT_CATALOG:
if item["name"].lower() == product_name.lower():
return item["price"]
return Decimal("1000.00")
def _pick_location(self, supplier: SupplierProfile, hubs: list, supplier_ratio: float) -> dict:
"""Pick location: supplier address (ratio) or hub."""
use_supplier = random.random() < supplier_ratio
if use_supplier:
location_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"supplier:{supplier.uuid}"))
return {
"uuid": location_uuid,
"name": f"{supplier.name} Warehouse",
"country": supplier.country,
"countryCode": supplier.country_code,
"latitude": supplier.latitude,
"longitude": supplier.longitude,
}
return random.choice(hubs) if hubs else {
"uuid": str(uuid.uuid4()),
"name": "Regional Hub",
"country": supplier.country,
"countryCode": supplier.country_code,
"latitude": supplier.latitude,
"longitude": supplier.longitude,
}
def _create_offers_via_workflow(
self, count: int, hubs: list, products: list, supplier_ratio: float, sleep_ms: int
) -> list:
"""Create offers via Temporal workflow (syncs to graph)"""
created = []
suppliers = list(SupplierProfile.objects.all())
if not suppliers:
self.stdout.write(self.style.ERROR("No suppliers found. Create suppliers first."))
return created
for idx in range(count):
supplier = random.choice(suppliers)
hub = self._pick_location(supplier, hubs, supplier_ratio)
product_name, category_name, product_uuid, product_price = random.choice(products)
data = OfferData(
team_uuid=supplier.team_uuid,
product_uuid=product_uuid,
product_name=product_name,
category_name=category_name,
location_uuid=hub["uuid"],
location_name=hub["name"],
location_country=hub["country"],
location_country_code=hub.get("countryCode", ""),
location_latitude=hub["latitude"],
location_longitude=hub["longitude"],
quantity=self._rand_decimal(10, 500, 2),
unit="ton",
price_per_unit=product_price,
currency="USD",
description=f"{product_name} available from {hub['name']} in {hub['country']}",
)
try:
offer_uuid, workflow_id, _ = OfferService.create_offer_via_workflow(data)
self.stdout.write(f" [{idx+1}/{count}] Created offer {offer_uuid[:8]}... workflow: {workflow_id}")
created.append(offer_uuid)
except Exception as e:
self.stdout.write(self.style.ERROR(f" [{idx+1}/{count}] Failed: {e}"))
if sleep_ms:
time.sleep(sleep_ms / 1000.0)
return created
def _create_offers_direct(
self, count: int, hubs: list, products: list, supplier_ratio: float, sleep_ms: int
) -> list:
"""Create offers directly in DB (no workflow, no graph sync)"""
created = []
suppliers = list(SupplierProfile.objects.all())
if not suppliers:
self.stdout.write(self.style.ERROR("No suppliers found. Create suppliers first."))
return created
for idx in range(count):
supplier = random.choice(suppliers)
hub = self._pick_location(supplier, hubs, supplier_ratio)
product_name, category_name, product_uuid, product_price = random.choice(products)
offer = Offer.objects.create(
uuid=str(uuid.uuid4()),
team_uuid=supplier.team_uuid,
status="active",
workflow_status="pending",
location_uuid=hub["uuid"],
location_name=hub["name"],
location_country=hub["country"],
location_country_code=hub.get("countryCode", ""),
location_latitude=hub["latitude"],
location_longitude=hub["longitude"],
product_uuid=product_uuid,
product_name=product_name,
category_name=category_name,
quantity=self._rand_decimal(10, 500, 2),
unit="ton",
price_per_unit=product_price,
currency="USD",
description=f"{product_name} available from {hub['name']} in {hub['country']}",
)
created.append(offer)
if sleep_ms:
time.sleep(sleep_ms / 1000.0)
return created
def _create_offers_direct_bulk(
self, count: int, hubs: list, products: list, supplier_ratio: float, bulk_size: int
) -> list:
"""Create offers in bulk (no workflow, no graph sync)"""
suppliers = list(SupplierProfile.objects.all())
if not suppliers:
self.stdout.write(self.style.ERROR("No suppliers found. Create suppliers first."))
return []
created_uuids: list[str] = []
batch: list[Offer] = []
for idx in range(count):
supplier = random.choice(suppliers)
hub = self._pick_location(supplier, hubs, supplier_ratio)
product_name, category_name, product_uuid, product_price = random.choice(products)
offer_uuid = str(uuid.uuid4())
batch.append(
Offer(
uuid=offer_uuid,
team_uuid=supplier.team_uuid,
status="active",
workflow_status="pending",
location_uuid=hub["uuid"],
location_name=hub["name"],
location_country=hub["country"],
location_country_code=hub.get("countryCode", ""),
location_latitude=hub["latitude"],
location_longitude=hub["longitude"],
product_uuid=product_uuid,
product_name=product_name,
category_name=category_name,
quantity=self._rand_decimal(10, 500, 2),
unit="ton",
price_per_unit=product_price,
currency="USD",
description=f"{product_name} available from {hub['name']} in {hub['country']}",
)
)
created_uuids.append(offer_uuid)
if len(batch) >= bulk_size:
Offer.objects.bulk_create(batch, batch_size=bulk_size)
batch = []
if batch:
Offer.objects.bulk_create(batch, batch_size=bulk_size)
return created_uuids
def _get_random_african_country(self) -> tuple:
"""Get random African country name and code"""
country, code, _, _ = random.choice(AFRICAN_COUNTRIES)
return country, code
def _get_country_coords(self, country: str) -> tuple:
"""Get default coordinates for a country"""
for name, code, lat, lng in AFRICAN_COUNTRIES:
if name == country:
return lat, lng
return 6.0, 0.0 # Default: Gulf of Guinea
def _rand_decimal(self, low: int, high: int, places: int) -> Decimal:
value = random.uniform(low, high)
quantize_str = "1." + "0" * places
return Decimal(str(value)).quantize(Decimal(quantize_str))
-56
View File
@@ -1,56 +0,0 @@
# Generated manually for exchange refactoring
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Offer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('team_uuid', models.CharField(max_length=100)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('active', 'Активно'), ('closed', 'Закрыто'), ('cancelled', 'Отменено')], default='active', max_length=50)),
('location_uuid', models.CharField(max_length=100)),
('location_name', models.CharField(blank=True, default='', max_length=255)),
('valid_until', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'offers',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='OfferLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('product_uuid', models.CharField(max_length=100)),
('product_name', models.CharField(blank=True, default='', max_length=255)),
('category_name', models.CharField(blank=True, default='', max_length=255)),
('quantity', models.DecimalField(decimal_places=2, max_digits=10)),
('unit', models.CharField(default='ton', max_length=20)),
('price_per_unit', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('currency', models.CharField(default='USD', max_length=3)),
('created_at', models.DateTimeField(auto_now_add=True)),
('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='offers.offer')),
],
options={
'db_table': 'offer_lines',
'ordering': ['id'],
},
),
]
@@ -1,80 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-10 04:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('offers', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='offer',
name='title',
),
migrations.AddField(
model_name='offer',
name='category_name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='currency',
field=models.CharField(default='USD', max_length=3),
),
migrations.AddField(
model_name='offer',
name='location_country',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.AddField(
model_name='offer',
name='location_country_code',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AddField(
model_name='offer',
name='location_latitude',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='offer',
name='location_longitude',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='offer',
name='price_per_unit',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True),
),
migrations.AddField(
model_name='offer',
name='product_name',
field=models.CharField(default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='product_uuid',
field=models.CharField(default='', max_length=100),
),
migrations.AddField(
model_name='offer',
name='quantity',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AddField(
model_name='offer',
name='unit',
field=models.CharField(default='ton', max_length=20),
),
migrations.AlterField(
model_name='offer',
name='location_uuid',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.DeleteModel(
name='OfferLine',
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-30 02:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('offers', '0002_remove_offer_title_offer_category_name_and_more'),
]
operations = [
migrations.AddField(
model_name='offer',
name='workflow_status',
field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-30 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('offers', '0003_offer_workflow_status'),
]
operations = [
migrations.AddField(
model_name='offer',
name='terminus_document_id',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='terminus_schema_id',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='workflow_error',
field=models.TextField(blank=True, default=''),
),
]
View File
-64
View File
@@ -1,64 +0,0 @@
from django.db import models
import uuid
class Offer(models.Model):
"""Оффер (предложение) от поставщика в каталоге — один товар по одной цене"""
STATUS_CHOICES = [
('draft', 'Черновик'),
('active', 'Активно'),
('closed', 'Закрыто'),
('cancelled', 'Отменено'),
]
WORKFLOW_STATUS_CHOICES = [
('pending', 'Ожидает обработки'),
('active', 'Активен'),
('error', 'Ошибка'),
]
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
team_uuid = models.CharField(max_length=100) # Команда поставщика
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='active')
workflow_status = models.CharField(
max_length=20,
choices=WORKFLOW_STATUS_CHOICES,
default='pending',
)
workflow_error = models.TextField(blank=True, default='')
# Локация отгрузки
location_uuid = models.CharField(max_length=100, blank=True, default='')
location_name = models.CharField(max_length=255, blank=True, default='')
location_country = models.CharField(max_length=100, blank=True, default='')
location_country_code = models.CharField(max_length=3, blank=True, default='')
location_latitude = models.FloatField(null=True, blank=True)
location_longitude = models.FloatField(null=True, blank=True)
# Товар
product_uuid = models.CharField(max_length=100, default='')
product_name = models.CharField(max_length=255, default='')
category_name = models.CharField(max_length=255, blank=True, default='')
# Количество и цена
quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0)
unit = models.CharField(max_length=20, default='ton')
price_per_unit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
currency = models.CharField(max_length=3, default='USD')
# Описание (опционально)
description = models.TextField(blank=True, default='')
terminus_schema_id = models.CharField(max_length=255, blank=True, default='')
terminus_document_id = models.CharField(max_length=255, blank=True, default='')
# Срок действия
valid_until = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'offers'
ordering = ['-created_at']
def __str__(self):
return f"{self.product_name} - {self.quantity} {self.unit} ({self.status})"
-119
View File
@@ -1,119 +0,0 @@
"""
Сервис для создания офферов через Temporal workflow.
Используется в Django admin action и в seed командах.
"""
import uuid
import logging
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional, Tuple
from exchange.temporal_client import start_offer_workflow
from suppliers.models import SupplierProfile
logger = logging.getLogger(__name__)
def get_supplier_uuid(team_uuid: str) -> Optional[str]:
"""Get supplier public UUID from team_uuid."""
try:
supplier = SupplierProfile.objects.get(team_uuid=team_uuid)
return supplier.uuid
except SupplierProfile.DoesNotExist:
logger.warning(f"SupplierProfile not found for team_uuid: {team_uuid}")
return None
@dataclass
class OfferData:
"""Данные для создания оффера"""
team_uuid: str
product_uuid: str
product_name: str
location_uuid: str
location_name: str
location_country: str
location_country_code: str
location_latitude: float
location_longitude: float
quantity: Decimal
unit: str = "ton"
price_per_unit: Optional[Decimal] = None
currency: str = "USD"
category_name: str = ""
description: str = ""
class OfferService:
"""Сервис для создания офферов через workflow"""
@staticmethod
def create_offer_via_workflow(data: OfferData) -> Tuple[str, str, str]:
"""
Создает оффер через Temporal workflow.
Returns:
Tuple[offer_uuid, workflow_id, run_id]
"""
offer_uuid = str(uuid.uuid4())
supplier_uuid = get_supplier_uuid(data.team_uuid)
workflow_id, run_id = start_offer_workflow(
offer_uuid=offer_uuid,
team_uuid=data.team_uuid,
supplier_uuid=supplier_uuid,
product_uuid=data.product_uuid,
product_name=data.product_name,
category_name=data.category_name,
location_uuid=data.location_uuid,
location_name=data.location_name,
location_country=data.location_country,
location_country_code=data.location_country_code,
location_latitude=data.location_latitude,
location_longitude=data.location_longitude,
quantity=data.quantity,
unit=data.unit,
price_per_unit=data.price_per_unit,
currency=data.currency,
description=data.description,
)
logger.info(f"Started offer workflow: {workflow_id} for offer {offer_uuid}")
return offer_uuid, workflow_id, run_id
@staticmethod
def resync_offer_via_workflow(offer) -> Tuple[str, str]:
"""
Пересоздает workflow для существующего оффера.
Используется для пере-синхронизации в граф.
Args:
offer: Offer model instance
Returns:
Tuple[workflow_id, run_id]
"""
supplier_uuid = get_supplier_uuid(offer.team_uuid)
workflow_id, run_id = start_offer_workflow(
offer_uuid=offer.uuid,
team_uuid=offer.team_uuid,
supplier_uuid=supplier_uuid,
product_uuid=offer.product_uuid,
product_name=offer.product_name,
category_name=offer.category_name,
location_uuid=offer.location_uuid,
location_name=offer.location_name,
location_country=offer.location_country,
location_country_code=offer.location_country_code,
location_latitude=offer.location_latitude,
location_longitude=offer.location_longitude,
quantity=offer.quantity,
unit=offer.unit,
price_per_unit=offer.price_per_unit,
currency=offer.currency,
description=offer.description,
)
logger.info(f"Restarted offer workflow: {workflow_id} for offer {offer.uuid}")
return workflow_id, run_id
+3498
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "exchange",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc",
"start": "prisma migrate deploy && node dist/index.js",
"seed:demo": "tsx scripts/seed-demo.ts",
"seed:reference": "tsx scripts/seed-reference.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@sentry/node": "^10.56.0",
"fastify": "^5.8.5",
"graphql": "^16.14.1",
"mercurius": "^16.9.0",
"pg": "^8.21.0",
"tsx": "^4.22.4"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
"typescript": "^6.0.3"
}
}
Generated
-1022
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: env('EXCHANGE_DATABASE_URL'),
},
})
+81
View File
@@ -0,0 +1,81 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "offers" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"team_uuid" VARCHAR(100) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"workflow_status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"workflow_error" TEXT,
"location_uuid" VARCHAR(100),
"location_name" VARCHAR(255) NOT NULL DEFAULT '',
"location_country" VARCHAR(100) NOT NULL DEFAULT '',
"location_country_code" VARCHAR(10) NOT NULL DEFAULT '',
"location_latitude" DOUBLE PRECISION,
"location_longitude" DOUBLE PRECISION,
"product_uuid" VARCHAR(100) NOT NULL,
"product_name" VARCHAR(255) NOT NULL,
"category_name" VARCHAR(255) NOT NULL DEFAULT '',
"quantity" DECIMAL(12,2) NOT NULL,
"unit" VARCHAR(20) NOT NULL DEFAULT 'ton',
"price_per_unit" DECIMAL(12,2) NOT NULL,
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
"terminus_schema_id" VARCHAR(255),
"terminus_document_id" VARCHAR(255),
"description" TEXT,
"valid_until" DATE,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "offers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calculations" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"product_uuid" VARCHAR(100) NOT NULL,
"quantity" DECIMAL(12,2) NOT NULL,
"source_location_uuid" VARCHAR(100) NOT NULL,
"user_id" VARCHAR(255) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calculations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "suppliers" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"team_uuid" VARCHAR(100) NOT NULL,
"kyc_profile_uuid" VARCHAR(100),
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"country" VARCHAR(100) NOT NULL DEFAULT '',
"country_code" VARCHAR(10) NOT NULL DEFAULT '',
"logo_url" VARCHAR(500),
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"is_verified" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "offers_uuid_key" ON "offers"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "calculations_uuid_key" ON "calculations"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "suppliers_uuid_key" ON "suppliers"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "suppliers_team_uuid_key" ON "suppliers"("team_uuid");
@@ -0,0 +1,98 @@
-- DropTable
DROP TABLE "offers";
-- DropTable
DROP TABLE "calculations";
-- DropTable
DROP TABLE "suppliers";
-- CreateTable
CREATE TABLE "exchange_quotes" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"supplier_id" INTEGER NOT NULL,
"product_id" INTEGER NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"quantity" DECIMAL(12,2) NOT NULL,
"unit" VARCHAR(20) NOT NULL DEFAULT 'ton',
"price_per_unit" DECIMAL(12,2) NOT NULL,
"currency" VARCHAR(10) NOT NULL DEFAULT 'USD',
"incoterms_code" VARCHAR(20),
"origin_point_uuid" VARCHAR(100),
"origin_name" VARCHAR(255),
"valid_until" DATE,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_quotes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_suppliers" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"team_uuid" VARCHAR(100),
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"country" VARCHAR(100) NOT NULL DEFAULT '',
"country_code" VARCHAR(10) NOT NULL DEFAULT '',
"logo_url" VARCHAR(500),
"is_verified" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_suppliers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exchange_products" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"sku" VARCHAR(100),
"name" VARCHAR(255) NOT NULL,
"category_name" VARCHAR(255) NOT NULL DEFAULT '',
"unit" VARCHAR(20) NOT NULL DEFAULT 'ton',
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "exchange_products_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "exchange_quotes_uuid_key" ON "exchange_quotes"("uuid");
-- CreateIndex
CREATE INDEX "exchange_quotes_status_product_id_created_at_idx" ON "exchange_quotes"("status", "product_id", "created_at");
-- CreateIndex
CREATE INDEX "exchange_quotes_supplier_id_created_at_idx" ON "exchange_quotes"("supplier_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_suppliers_uuid_key" ON "exchange_suppliers"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_suppliers_team_uuid_key" ON "exchange_suppliers"("team_uuid");
-- CreateIndex
CREATE INDEX "exchange_suppliers_country_code_is_active_idx" ON "exchange_suppliers"("country_code", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_products_uuid_key" ON "exchange_products"("uuid");
-- CreateIndex
CREATE UNIQUE INDEX "exchange_products_sku_key" ON "exchange_products"("sku");
-- CreateIndex
CREATE INDEX "exchange_products_category_name_is_active_idx" ON "exchange_products"("category_name", "is_active");
-- AddForeignKey
ALTER TABLE "exchange_quotes" ADD CONSTRAINT "exchange_quotes_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "exchange_suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "exchange_quotes" ADD CONSTRAINT "exchange_quotes_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "exchange_products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "exchange_products" ADD COLUMN "image_url" VARCHAR(500);
+71
View File
@@ -0,0 +1,71 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model Quote {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
supplierId Int @map("supplier_id")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
productId Int @map("product_id")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
status String @default("active") @db.VarChar(20)
quantity Decimal @db.Decimal(12, 2)
unit String @default("ton") @db.VarChar(20)
pricePerUnit Decimal @map("price_per_unit") @db.Decimal(12, 2)
currency String @default("USD") @db.VarChar(10)
incotermsCode String? @map("incoterms_code") @db.VarChar(20)
originPointUuid String? @map("origin_point_uuid") @db.VarChar(100)
originName String? @map("origin_name") @db.VarChar(255)
validUntil DateTime? @map("valid_until") @db.Date
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status, productId, createdAt])
@@index([supplierId, createdAt])
@@map("exchange_quotes")
}
model Supplier {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamUuid String? @unique @map("team_uuid") @db.VarChar(100)
name String @db.VarChar(255)
description String?
country String @default("") @db.VarChar(100)
countryCode String @default("") @map("country_code") @db.VarChar(10)
logoUrl String? @map("logo_url") @db.VarChar(500)
isVerified Boolean @default(false) @map("is_verified")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
quotes Quote[]
@@index([countryCode, isActive])
@@map("exchange_suppliers")
}
model Product {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
sku String? @unique @db.VarChar(100)
name String @db.VarChar(255)
categoryName String @default("") @map("category_name") @db.VarChar(255)
unit String @default("ton") @db.VarChar(20)
imageUrl String? @map("image_url") @db.VarChar(500)
description String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
quotes Quote[]
@@index([categoryName, isActive])
@@map("exchange_products")
}
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
-9
View File
@@ -1,9 +0,0 @@
from django.contrib import admin
from .models import Request
@admin.register(Request)
class RequestAdmin(admin.ModelAdmin):
list_display = ['uuid', 'product_uuid', 'quantity', 'user_id', 'created_at']
list_filter = ['created_at']
search_fields = ['uuid', 'user_id']
-6
View File
@@ -1,6 +0,0 @@
from django.apps import AppConfig
class PurchaseRequestsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'purchase_requests'
@@ -1,32 +0,0 @@
# Generated manually for exchange refactoring
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Request',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
('product_uuid', models.CharField(max_length=100)),
('quantity', models.DecimalField(decimal_places=2, max_digits=10)),
('source_location_uuid', models.CharField(max_length=100)),
('user_id', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'calculations',
'ordering': ['-created_at'],
},
),
]
-21
View File
@@ -1,21 +0,0 @@
from django.db import models
import uuid
class Request(models.Model):
"""Заявка покупателя (RFQ - Request For Quotation)"""
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
product_uuid = models.CharField(max_length=100)
quantity = models.DecimalField(max_digits=10, decimal_places=2)
source_location_uuid = models.CharField(max_length=100)
user_id = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'calculations' # Keep old table name for data compatibility
ordering = ['-created_at']
def __str__(self):
return f"Заявка {self.uuid} - {self.quantity}"
-28
View File
@@ -1,28 +0,0 @@
[project]
name = "exchange"
version = "0.1.0"
description = "Exchange backend service (offers & requests)"
authors = [
{name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"}
]
readme = "README.md"
requires-python = "^3.11"
dependencies = [
"django (>=5.2.8,<6.0)",
"gunicorn (>=23.0.0,<24.0.0)",
"whitenoise (>=6.11.0,<7.0.0)",
"django-environ (>=0.12.0,<0.13.0)",
"sentry-sdk (>=2.46.0,<3.0.0)",
"python-dotenv (>=1.2.1,<2.0.0)",
"django-cors-headers (>=4.9.0,<5.0.0)",
"graphene-django (>=3.2.3,<4.0.0)",
"psycopg2-binary (>=2.9.11,<3.0.0)",
"infisicalsdk (>=1.0.12,<2.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
"cryptography (>=46.0.3,<47.0.0)",
"temporalio (>=1.21.1,<2.0.0)",
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
+44
View File
@@ -0,0 +1,44 @@
import { InfisicalSDK } from "@infisical/sdk";
import { writeFileSync } from "fs";
const INFISICAL_API_URL = process.env.INFISICAL_API_URL;
const INFISICAL_CLIENT_ID = process.env.INFISICAL_CLIENT_ID;
const INFISICAL_CLIENT_SECRET = process.env.INFISICAL_CLIENT_SECRET;
const INFISICAL_PROJECT_ID = process.env.INFISICAL_PROJECT_ID;
const INFISICAL_ENV = process.env.INFISICAL_ENV || "prod";
const SECRET_PATHS = (process.env.INFISICAL_SECRET_PATHS || "/shared").split(",");
if (!INFISICAL_API_URL || !INFISICAL_CLIENT_ID || !INFISICAL_CLIENT_SECRET || !INFISICAL_PROJECT_ID) {
process.stderr.write("Missing required Infisical environment variables\n");
process.exit(1);
}
const client = new InfisicalSDK({ siteUrl: INFISICAL_API_URL });
await client.auth().universalAuth.login({
clientId: INFISICAL_CLIENT_ID,
clientSecret: INFISICAL_CLIENT_SECRET,
});
process.stderr.write(`Loading secrets from Infisical (env: ${INFISICAL_ENV})...\n`);
const envLines = [];
for (const secretPath of SECRET_PATHS) {
const response = await client.secrets().listSecrets({
projectId: INFISICAL_PROJECT_ID,
environment: INFISICAL_ENV,
secretPath: secretPath.trim(),
expandSecretReferences: true,
});
for (const secret of response.secrets) {
const escapedValue = secret.secretValue.replace(/'/g, "'\\''");
envLines.push(`export ${secret.secretKey}='${escapedValue}'`);
}
process.stderr.write(` ${secretPath.trim()}: ${response.secrets.length} secrets loaded\n`);
}
writeFileSync(".env.infisical", envLines.join("\n"));
process.stderr.write("Secrets written to .env.infisical\n");
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
set -eu
log() {
printf '%s\n' "$*" >&2
}
VAULT_ENABLED="${VAULT_ENABLED:-auto}"
if [ "$VAULT_ENABLED" = "false" ] || [ "$VAULT_ENABLED" = "0" ]; then
exit 0
fi
if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then
if [ "$VAULT_ENABLED" = "true" ] || [ "$VAULT_ENABLED" = "1" ]; then
log "Vault bootstrap is required but VAULT_ADDR or VAULT_TOKEN is missing."
exit 1
fi
exit 0
fi
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
log "Vault bootstrap requires curl and jq."
exit 1
fi
VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-secret}"
load_secret_path() {
path="$1"
source_name="$2"
if [ -z "$path" ]; then
return 0
fi
url="${VAULT_ADDR%/}/v1/${VAULT_KV_MOUNT}/data/${path}"
response="$(curl -fsS -H "X-Vault-Token: $VAULT_TOKEN" "$url")" || {
log "Failed to load Vault path ${VAULT_KV_MOUNT}/${path}."
return 1
}
encoded_items="$(printf '%s' "$response" | jq -r '.data.data // {} | to_entries[]? | @base64')"
if [ -z "$encoded_items" ]; then
return 0
fi
old_ifs="${IFS}"
IFS='
'
for encoded_item in $encoded_items; do
key="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.key')"
value="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.value | tostring')"
export "$key=$value"
done
IFS="${old_ifs}"
log "Loaded Vault ${source_name} secrets from ${VAULT_KV_MOUNT}/${path}."
}
load_secret_path "${VAULT_SHARED_PATH:-}" "shared"
load_secret_path "${VAULT_PROJECT_PATH:-}" "project"
+147
View File
@@ -0,0 +1,147 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../dist/db.js'
const DEMO_TEAM_UUID = process.env.DEMO_TEAM_UUID ?? '11111111-1111-4111-8111-111111111111'
const products = [
{
uuid: '55555555-1111-4111-8111-111111111111',
sku: 'DEMO-SOLAR-PANELS',
name: 'Solar panels',
categoryName: 'Equipment',
unit: 'pallet',
imageUrl: 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=900&q=80',
description: 'Demo exchange product',
},
{
uuid: '55555555-1111-4111-8111-222222222222',
sku: 'DEMO-INDUSTRIAL-PUMPS',
name: 'Industrial pumps',
categoryName: 'Equipment',
unit: 'unit',
imageUrl: 'https://images.unsplash.com/photo-1581092160607-ee22621dd758?auto=format&fit=crop&w=900&q=80',
description: 'Demo exchange product',
},
{
uuid: '55555555-1111-4111-8111-333333333333',
sku: 'DEMO-SOYBEAN-MEAL',
name: 'Soybean meal',
categoryName: 'Agriculture',
unit: 'ton',
imageUrl: 'https://images.unsplash.com/photo-1625246333195-78d9c38ad449?auto=format&fit=crop&w=900&q=80',
description: 'Demo exchange product',
},
]
const suppliers = [
{
uuid: '66666666-2222-4111-8111-111111111111',
teamUuid: DEMO_TEAM_UUID,
name: 'Guangzhou Solar Factory',
description: 'Demo solar panel supplier near Guangzhou rail terminal',
country: 'China',
countryCode: 'CN',
logoUrl: 'https://ui-avatars.com/api/?name=Guangzhou+Solar+Factory&background=0B6BFF&color=fff&bold=true',
isVerified: true,
},
{
uuid: '66666666-2222-4111-8111-222222222222',
teamUuid: null,
name: 'Shenzhen Pump Works',
description: 'Demo industrial supplier near Yantian sea port',
country: 'China',
countryCode: 'CN',
logoUrl: 'https://ui-avatars.com/api/?name=Shenzhen+Pump+Works&background=9F1239&color=fff&bold=true',
isVerified: true,
},
{
uuid: '66666666-2222-4111-8111-333333333333',
teamUuid: null,
name: 'Harbin Agro Export',
description: 'Demo agriculture supplier near rail export hub',
country: 'China',
countryCode: 'CN',
logoUrl: 'https://ui-avatars.com/api/?name=Harbin+Agro+Export&background=0F8A6A&color=fff&bold=true',
isVerified: true,
},
{
uuid: '66666666-2222-4111-8111-444444444444',
teamUuid: null,
name: 'Vladivostok Timber Terminal',
description: 'Demo Russian supplier near Far East port and rail',
country: 'Russia',
countryCode: 'RU',
logoUrl: 'https://ui-avatars.com/api/?name=Vladivostok+Timber+Terminal&background=C45125&color=fff&bold=true',
isVerified: true,
},
]
function quoteUuid(index: number) {
return `77777777-3333-4${String(index).padStart(3, '0')}-8${String(index).padStart(3, '0')}-777777${String(index).padStart(6, '0')}`
}
for (const product of products) {
await prisma.product.upsert({
where: { uuid: product.uuid },
create: { ...product, isActive: true },
update: { ...product, isActive: true },
})
}
for (const supplier of suppliers) {
await prisma.supplier.upsert({
where: { uuid: supplier.uuid },
create: { ...supplier, isActive: true },
update: { ...supplier, isActive: true },
})
}
for (let index = 0; index < 24; index += 1) {
const productSeed = products[index % products.length]
const supplierSeed = suppliers[Math.floor(index / products.length) % suppliers.length]
const product = await prisma.product.findUniqueOrThrow({ where: { uuid: productSeed.uuid } })
const supplier = await prisma.supplier.findUniqueOrThrow({ where: { uuid: supplierSeed.uuid } })
const originPointUuid = [
'logistics-demo-hub-1',
'logistics-demo-hub-2',
'logistics-demo-hub-3',
'logistics-demo-hub-4',
][Math.floor(index / products.length) % suppliers.length]
await prisma.quote.upsert({
where: { uuid: quoteUuid(index) },
create: {
uuid: quoteUuid(index),
supplierId: supplier.id,
productId: product.id,
status: 'active',
quantity: new Prisma.Decimal(500 + index * 25),
unit: product.unit,
pricePerUnit: new Prisma.Decimal(900 + (index % 8) * 55),
currency: 'USD',
incotermsCode: index % 2 === 0 ? 'FOB' : 'DAP',
originPointUuid,
originName: supplier.name,
validUntil: new Date('2027-12-31'),
notes: 'Demo seed quote',
},
update: {
supplierId: supplier.id,
productId: product.id,
status: 'active',
quantity: new Prisma.Decimal(500 + index * 25),
unit: product.unit,
pricePerUnit: new Prisma.Decimal(900 + (index % 8) * 55),
currency: 'USD',
incotermsCode: index % 2 === 0 ? 'FOB' : 'DAP',
originPointUuid,
originName: supplier.name,
validUntil: new Date('2027-12-31'),
notes: 'Demo seed quote',
},
})
}
console.log(`Seeded exchange demo data: products ${products.length}, suppliers ${suppliers.length}, quotes 24`)
await prisma.$disconnect()
+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()
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { convertMoney, displayCurrency, parseCurrencyRates } from './currency-rates.js'
const payload = {
result: 'success',
provider: 'https://www.exchangerate-api.com',
documentation: 'https://www.exchangerate-api.com/docs/free',
terms_of_use: 'https://www.exchangerate-api.com/terms',
time_last_update_unix: 1_787_529_751,
time_next_update_unix: 1_787_617_021,
base_code: 'USD',
rates: {
USD: 1,
RUB: 82.802865,
CNY: 6.729114,
EUR: 0.85618,
AED: 3.6725,
},
}
test('converts USD into the selected display currency', () => {
const rates = parseCurrencyRates(payload)
assert.equal(convertMoney(100, 'USD', 'RUB', rates), 8_280.2865)
})
test('converts between two non-USD currencies through the USD base', () => {
const rates = parseCurrencyRates(payload)
assert.equal(convertMoney(82.802865, 'RUB', 'EUR', rates), 0.85618)
})
test('rejects unsupported display currencies', () => {
assert.throws(() => displayCurrency('GBP'), /Unsupported display currency/)
})
test('rejects incomplete provider responses', () => {
assert.throws(
() => parseCurrencyRates({ ...payload, rates: { ...payload.rates, RUB: 0 } }),
/no valid RUB rate/,
)
})
+117
View File
@@ -0,0 +1,117 @@
export const DISPLAY_CURRENCIES = ['USD', 'RUB', 'CNY', 'EUR', 'AED'] as const
export type DisplayCurrency = typeof DISPLAY_CURRENCIES[number]
type ExchangeRateApiResponse = {
result?: string
provider?: string
documentation?: string
terms_of_use?: string
time_last_update_unix?: number
time_next_update_unix?: number
base_code?: string
rates?: Record<string, number>
'error-type'?: string
}
export type CurrencyRatesSnapshot = {
baseCurrency: 'USD'
rates: Record<string, number>
updatedAt: Date
nextUpdateAt: Date
provider: string
documentation: string
termsOfUse: string
attributionUrl: string
}
const exchangeRateApiUrl = 'https://open.er-api.com/v6/latest/USD'
const displayCurrencyCodes = new Set<string>(DISPLAY_CURRENCIES)
let cachedRates: CurrencyRatesSnapshot | null = null
export function displayCurrency(value: unknown): DisplayCurrency {
const normalized = String(value ?? '').trim().toUpperCase()
if (!displayCurrencyCodes.has(normalized)) {
throw new Error(`Unsupported display currency: ${normalized || 'empty'}`)
}
return normalized as DisplayCurrency
}
export function parseCurrencyRates(payload: ExchangeRateApiResponse): CurrencyRatesSnapshot {
if (payload.result !== 'success') {
throw new Error(`ExchangeRate-API error: ${payload['error-type'] ?? payload.result ?? 'invalid response'}`)
}
if (payload.base_code !== 'USD') {
throw new Error(`ExchangeRate-API returned unexpected base currency: ${payload.base_code ?? 'empty'}`)
}
if (!payload.rates || !payload.time_last_update_unix || !payload.time_next_update_unix) {
throw new Error('ExchangeRate-API response is missing rates or update timestamps')
}
if (!payload.provider || !payload.documentation || !payload.terms_of_use) {
throw new Error('ExchangeRate-API response is missing attribution metadata')
}
for (const code of DISPLAY_CURRENCIES) {
const rate = payload.rates[code]
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
throw new Error(`ExchangeRate-API response has no valid ${code} rate`)
}
}
return {
baseCurrency: 'USD',
rates: payload.rates,
updatedAt: new Date(payload.time_last_update_unix * 1000),
nextUpdateAt: new Date(payload.time_next_update_unix * 1000),
provider: payload.provider,
documentation: payload.documentation,
termsOfUse: payload.terms_of_use,
attributionUrl: 'https://www.exchangerate-api.com',
}
}
export async function currencyRates(): Promise<CurrencyRatesSnapshot> {
if (cachedRates && Date.now() < cachedRates.nextUpdateAt.getTime()) {
return cachedRates
}
const response = await fetch(exchangeRateApiUrl)
if (!response.ok) {
throw new Error(`ExchangeRate-API request failed: ${response.status}`)
}
cachedRates = parseCurrencyRates(await response.json() as ExchangeRateApiResponse)
return cachedRates
}
export function convertMoney(
amount: number,
sourceCurrency: string,
targetCurrency: DisplayCurrency,
snapshot: CurrencyRatesSnapshot,
) {
if (!Number.isFinite(amount)) {
throw new Error(`Invalid money amount: ${amount}`)
}
const normalizedSource = sourceCurrency.trim().toUpperCase()
const sourceRate = snapshot.rates[normalizedSource]
const targetRate = snapshot.rates[targetCurrency]
if (typeof sourceRate !== 'number' || !Number.isFinite(sourceRate) || sourceRate <= 0) {
throw new Error(`No exchange rate for source currency: ${normalizedSource || 'empty'}`)
}
return amount / sourceRate * targetRate
}
export async function convertPrices<T extends { pricePerUnit: unknown; currency: string }>(
rows: T[],
targetCurrencyValue: unknown,
) {
const targetCurrency = displayCurrency(targetCurrencyValue)
const needsConversion = rows.some(row => row.currency.trim().toUpperCase() !== targetCurrency)
if (!needsConversion) {
return rows.map(row => ({ ...row, pricePerUnit: Number(row.pricePerUnit), currency: targetCurrency }))
}
const snapshot = await currencyRates()
return rows.map(row => ({
...row,
pricePerUnit: convertMoney(Number(row.pricePerUnit), row.currency, targetCurrency, snapshot),
currency: targetCurrency,
}))
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
const connectionString = process.env.EXCHANGE_DATABASE_URL
if (!connectionString) {
throw new Error('EXCHANGE_DATABASE_URL is required')
}
const adapter = new PrismaPg({ connectionString })
export const prisma = new PrismaClient({ adapter })
+38
View File
@@ -0,0 +1,38 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import mercurius from "mercurius";
import * as Sentry from "@sentry/node";
import { resolvers, typeDefs } from "./schema.js";
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
const SENTRY_DSN = process.env.SENTRY_DSN || "";
const corsOrigins = (
process.env.CORS_ORIGINS || "https://optovia.ru,https://app.optovia.ru"
)
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || "1.0.0",
environment: process.env.ENVIRONMENT || "production",
});
}
const app = Fastify();
await app.register(cors, { origin: corsOrigins, credentials: true });
await app.register(mercurius, {
schema: typeDefs,
resolvers,
path: "/graphql",
graphiql: true,
});
app.get("/health", async () => ({ status: "ok", service: "exchange" }));
await app.listen({ port: PORT, host: "0.0.0.0" });
console.log(`Exchange server ready on port ${PORT}`);
console.log(" /graphql - suppliers, products, quotes");

Some files were not shown because too many files have changed in this diff Show More