Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60831b9f8c | ||
|
|
19d9a05211 | ||
|
|
2e3baf7634 | ||
|
|
37bd31524f | ||
|
|
5c52a98ba8 | ||
|
|
821b9c0a03 | ||
|
|
ed94ae514a | ||
|
|
9d5b9d7eab | ||
|
|
bf83727e5a | ||
|
|
71576fb65b | ||
|
|
f4c1f4ebfc | ||
|
|
aff1c63b44 | ||
|
|
a499e03401 | ||
|
|
5eae00961c | ||
|
|
43e4cba3b8 | ||
|
|
09d660585e | ||
|
|
53628ff0d3 | ||
|
|
de5b9ba025 | ||
|
|
0883a4ead8 | ||
|
|
052979965d | ||
|
|
bb5a202ec5 | ||
|
|
3169f09672 | ||
|
|
27b86c85b7 | ||
|
|
569924cabb |
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "graphql-contracts"]
|
||||
path = graphql-contracts
|
||||
url = git@gitea.dsrptlab.com:optovia/exchange-graphql-contracts.git
|
||||
+29
-16
@@ -1,24 +1,37 @@
|
||||
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
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node dist/index.js"]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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.
Binary file not shown.
@@ -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()
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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))),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Submodule
+1
Submodule graphql-contracts added at 93b8e21325
@@ -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()
|
||||
@@ -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"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OffersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'offers'
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
-28
@@ -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=''),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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})"
|
||||
@@ -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
|
||||
Generated
+3498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
},
|
||||
})
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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']
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
Executable
+60
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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");
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import { prisma } from './db.js'
|
||||
|
||||
type SupplierInput = {
|
||||
teamUuid?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
country?: string | null
|
||||
countryCode?: string | null
|
||||
logoUrl?: string | null
|
||||
isVerified?: boolean | null
|
||||
isActive?: boolean | null
|
||||
}
|
||||
|
||||
type ProductInput = {
|
||||
sku?: string | null
|
||||
name: string
|
||||
categoryName?: string | null
|
||||
unit?: string | null
|
||||
imageUrl?: string | null
|
||||
description?: string | null
|
||||
isActive?: boolean | null
|
||||
}
|
||||
|
||||
type QuoteInput = {
|
||||
supplierUuid: string
|
||||
productUuid: string
|
||||
status?: string | null
|
||||
quantity: number
|
||||
unit?: string | null
|
||||
pricePerUnit: number
|
||||
currency?: string | null
|
||||
incotermsCode?: string | null
|
||||
originPointUuid?: string | null
|
||||
originName?: string | null
|
||||
validUntil?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export const typeDefs = `#graphql
|
||||
type Supplier {
|
||||
uuid: ID!
|
||||
teamUuid: String
|
||||
name: String!
|
||||
description: String
|
||||
country: String!
|
||||
countryCode: String!
|
||||
logoUrl: String
|
||||
isVerified: Boolean!
|
||||
isActive: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Product {
|
||||
uuid: ID!
|
||||
sku: String
|
||||
name: String!
|
||||
categoryName: String!
|
||||
unit: String!
|
||||
imageUrl: String
|
||||
description: String
|
||||
isActive: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
type Quote {
|
||||
uuid: ID!
|
||||
supplier: Supplier!
|
||||
product: Product!
|
||||
status: String!
|
||||
quantity: Float!
|
||||
unit: String!
|
||||
pricePerUnit: Float!
|
||||
currency: String!
|
||||
incotermsCode: String
|
||||
originPointUuid: String
|
||||
originName: String
|
||||
validUntil: String
|
||||
notes: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
input SupplierInput {
|
||||
teamUuid: String
|
||||
name: String!
|
||||
description: String
|
||||
country: String
|
||||
countryCode: String
|
||||
logoUrl: String
|
||||
isVerified: Boolean
|
||||
isActive: Boolean
|
||||
}
|
||||
|
||||
input ProductInput {
|
||||
sku: String
|
||||
name: String!
|
||||
categoryName: String
|
||||
unit: String
|
||||
imageUrl: String
|
||||
description: String
|
||||
isActive: Boolean
|
||||
}
|
||||
|
||||
input QuoteInput {
|
||||
supplierUuid: ID!
|
||||
productUuid: ID!
|
||||
status: String
|
||||
quantity: Float!
|
||||
unit: String
|
||||
pricePerUnit: Float!
|
||||
currency: String
|
||||
incotermsCode: String
|
||||
originPointUuid: String
|
||||
originName: String
|
||||
validUntil: String
|
||||
notes: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
suppliers(countryCode: String, isVerified: Boolean, limit: Int, offset: Int): [Supplier!]!
|
||||
supplier(uuid: ID!): Supplier
|
||||
products(categoryName: String, search: String, limit: Int, offset: Int): [Product!]!
|
||||
product(uuid: ID!): Product
|
||||
quotes(productUuid: ID, supplierUuid: ID, status: String, limit: Int, offset: Int): [Quote!]!
|
||||
quote(uuid: ID!): Quote
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createSupplier(input: SupplierInput!): Supplier!
|
||||
updateSupplier(uuid: ID!, input: SupplierInput!): Supplier!
|
||||
createProduct(input: ProductInput!): Product!
|
||||
updateProduct(uuid: ID!, input: ProductInput!): Product!
|
||||
createQuote(input: QuoteInput!): Quote!
|
||||
updateQuote(uuid: ID!, input: QuoteInput!): Quote!
|
||||
deleteQuote(uuid: ID!): Boolean!
|
||||
}
|
||||
`
|
||||
|
||||
function mapDate(value: Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
function supplierData(input: SupplierInput) {
|
||||
return {
|
||||
teamUuid: input.teamUuid ?? null,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
country: input.country ?? '',
|
||||
countryCode: input.countryCode ?? '',
|
||||
logoUrl: input.logoUrl ?? null,
|
||||
isVerified: input.isVerified ?? false,
|
||||
isActive: input.isActive ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
function productData(input: ProductInput) {
|
||||
return {
|
||||
sku: input.sku ?? null,
|
||||
name: input.name,
|
||||
categoryName: input.categoryName ?? '',
|
||||
unit: input.unit ?? 'ton',
|
||||
imageUrl: input.imageUrl ?? null,
|
||||
description: input.description ?? null,
|
||||
isActive: input.isActive ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
async function quoteData(input: QuoteInput) {
|
||||
const supplier = await prisma.supplier.findUnique({ where: { uuid: input.supplierUuid } })
|
||||
if (supplier === null) throw new Error(`Supplier ${input.supplierUuid} does not exist`)
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { uuid: input.productUuid } })
|
||||
if (product === null) throw new Error(`Product ${input.productUuid} does not exist`)
|
||||
|
||||
return {
|
||||
supplierId: supplier.id,
|
||||
productId: product.id,
|
||||
status: input.status ?? 'active',
|
||||
quantity: input.quantity,
|
||||
unit: input.unit ?? product.unit,
|
||||
pricePerUnit: input.pricePerUnit,
|
||||
currency: input.currency ?? 'USD',
|
||||
incotermsCode: input.incotermsCode ?? null,
|
||||
originPointUuid: input.originPointUuid ?? null,
|
||||
originName: input.originName ?? null,
|
||||
validUntil: input.validUntil ? new Date(input.validUntil) : null,
|
||||
notes: input.notes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
suppliers: (_: unknown, args: { countryCode?: string; isVerified?: boolean; limit?: number; offset?: number }) =>
|
||||
prisma.supplier.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(args.countryCode ? { countryCode: args.countryCode } : {}),
|
||||
...(args.isVerified !== undefined ? { isVerified: args.isVerified } : {}),
|
||||
},
|
||||
take: args.limit ?? 50,
|
||||
skip: args.offset ?? 0,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
|
||||
supplier: (_: unknown, args: { uuid: string }) =>
|
||||
prisma.supplier.findUnique({ where: { uuid: args.uuid } }),
|
||||
|
||||
products: (_: unknown, args: { categoryName?: string; search?: string; limit?: number; offset?: number }) =>
|
||||
prisma.product.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(args.categoryName ? { categoryName: args.categoryName } : {}),
|
||||
...(args.search ? { name: { contains: args.search, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
take: args.limit ?? 50,
|
||||
skip: args.offset ?? 0,
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
|
||||
product: (_: unknown, args: { uuid: string }) =>
|
||||
prisma.product.findUnique({ where: { uuid: args.uuid } }),
|
||||
|
||||
quotes: async (_: unknown, args: { productUuid?: string; supplierUuid?: string; status?: string; limit?: number; offset?: number }) => {
|
||||
const product = args.productUuid ? await prisma.product.findUnique({ where: { uuid: args.productUuid } }) : null
|
||||
const supplier = args.supplierUuid ? await prisma.supplier.findUnique({ where: { uuid: args.supplierUuid } }) : null
|
||||
return prisma.quote.findMany({
|
||||
where: {
|
||||
status: args.status ?? 'active',
|
||||
...(product ? { productId: product.id } : {}),
|
||||
...(supplier ? { supplierId: supplier.id } : {}),
|
||||
},
|
||||
include: { supplier: true, product: true },
|
||||
take: args.limit ?? 50,
|
||||
skip: args.offset ?? 0,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
},
|
||||
|
||||
quote: (_: unknown, args: { uuid: string }) =>
|
||||
prisma.quote.findUnique({ where: { uuid: args.uuid }, include: { supplier: true, product: true } }),
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
createSupplier: (_: unknown, args: { input: SupplierInput }) =>
|
||||
prisma.supplier.create({ data: supplierData(args.input) }),
|
||||
|
||||
updateSupplier: (_: unknown, args: { uuid: string; input: SupplierInput }) =>
|
||||
prisma.supplier.update({ where: { uuid: args.uuid }, data: supplierData(args.input) }),
|
||||
|
||||
createProduct: (_: unknown, args: { input: ProductInput }) =>
|
||||
prisma.product.create({ data: productData(args.input) }),
|
||||
|
||||
updateProduct: (_: unknown, args: { uuid: string; input: ProductInput }) =>
|
||||
prisma.product.update({ where: { uuid: args.uuid }, data: productData(args.input) }),
|
||||
|
||||
createQuote: async (_: unknown, args: { input: QuoteInput }) =>
|
||||
prisma.quote.create({ data: await quoteData(args.input), include: { supplier: true, product: true } }),
|
||||
|
||||
updateQuote: async (_: unknown, args: { uuid: string; input: QuoteInput }) =>
|
||||
prisma.quote.update({ where: { uuid: args.uuid }, data: await quoteData(args.input), include: { supplier: true, product: true } }),
|
||||
|
||||
deleteQuote: async (_: unknown, args: { uuid: string }) => {
|
||||
await prisma.quote.delete({ where: { uuid: args.uuid } })
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
Supplier: {
|
||||
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
|
||||
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
|
||||
},
|
||||
|
||||
Product: {
|
||||
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
|
||||
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
|
||||
},
|
||||
|
||||
Quote: {
|
||||
quantity: (parent: { quantity: unknown }) => Number(parent.quantity),
|
||||
pricePerUnit: (parent: { pricePerUnit: unknown }) => Number(parent.pricePerUnit),
|
||||
validUntil: (parent: { validUntil: Date | null }) => parent.validUntil?.toISOString() ?? null,
|
||||
createdAt: (parent: { createdAt: Date }) => mapDate(parent.createdAt),
|
||||
updatedAt: (parent: { updatedAt: Date }) => mapDate(parent.updatedAt),
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from .models import SupplierProfile
|
||||
|
||||
|
||||
@admin.register(SupplierProfile)
|
||||
class SupplierProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'country', 'is_verified', 'is_active', 'created_at']
|
||||
list_filter = ['is_verified', 'is_active', 'country']
|
||||
search_fields = ['name', 'description', 'team_uuid']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SuppliersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'suppliers'
|
||||
verbose_name = 'Профили поставщиков'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user