Initial commit from monorepo
This commit is contained in:
0
offers/__init__.py
Normal file
0
offers/__init__.py
Normal file
55
offers/admin.py
Normal file
55
offers/admin.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.contrib import admin, messages
|
||||
|
||||
from .models import Offer
|
||||
from .services import OfferService
|
||||
|
||||
|
||||
@admin.register(Offer)
|
||||
class OfferAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'product_name',
|
||||
'status',
|
||||
'workflow_status',
|
||||
'team_uuid',
|
||||
'location_name',
|
||||
'location_country',
|
||||
'quantity',
|
||||
'price_per_unit',
|
||||
'created_at',
|
||||
]
|
||||
list_filter = ['status', 'workflow_status', 'created_at', 'category_name', 'location_country']
|
||||
search_fields = ['product_name', 'description', 'location_name', 'uuid']
|
||||
readonly_fields = ['uuid', 'workflow_status', 'workflow_error', 'created_at', 'updated_at']
|
||||
actions = ['sync_to_graph']
|
||||
|
||||
@admin.action(description="Синхронизировать в граф (запустить workflow)")
|
||||
def sync_to_graph(self, request, queryset):
|
||||
"""Запускает workflow для пересинхронизации выбранных офферов в ArangoDB граф"""
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for offer in queryset:
|
||||
try:
|
||||
workflow_id, run_id = OfferService.resync_offer_via_workflow(offer)
|
||||
offer.workflow_status = 'pending'
|
||||
offer.workflow_error = ''
|
||||
offer.save(update_fields=['workflow_status', 'workflow_error'])
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
offer.workflow_status = 'error'
|
||||
offer.workflow_error = str(e)
|
||||
offer.save(update_fields=['workflow_status', 'workflow_error'])
|
||||
error_count += 1
|
||||
|
||||
if success_count:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Запущен workflow для {success_count} офферов",
|
||||
messages.SUCCESS,
|
||||
)
|
||||
if error_count:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Ошибка при запуске workflow для {error_count} офферов",
|
||||
messages.ERROR,
|
||||
)
|
||||
6
offers/apps.py
Normal file
6
offers/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OffersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'offers'
|
||||
1
offers/management/__init__.py
Normal file
1
offers/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
offers/management/commands/__init__.py
Normal file
1
offers/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
405
offers/management/commands/seed_exchange.py
Normal file
405
offers/management/commands/seed_exchange.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Seed Suppliers and Offers for African cocoa belt.
|
||||
Creates offers via Temporal workflow so they sync to the graph.
|
||||
"""
|
||||
import random
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
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é
|
||||
]
|
||||
|
||||
# Products will be fetched from Odoo at runtime
|
||||
# Format: (name, category, uuid) - populated by _fetch_products_from_odoo()
|
||||
|
||||
|
||||
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(
|
||||
"--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(
|
||||
"--geo-url",
|
||||
type=str,
|
||||
default="http://geo:8000/graphql/public/",
|
||||
help="Geo service GraphQL URL (default: http://geo:8000/graphql/public/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--odoo-url",
|
||||
type=str,
|
||||
default="http://odoo:8069",
|
||||
help="Odoo URL (default: http://odoo:8069)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--product",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Filter offers by product name (e.g., 'Cocoa Beans')",
|
||||
)
|
||||
|
||||
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"])
|
||||
use_workflow = not options["no_workflow"]
|
||||
geo_url = options["geo_url"]
|
||||
odoo_url = options["odoo_url"]
|
||||
product_filter = options["product"]
|
||||
|
||||
# Fetch products from Odoo
|
||||
self.stdout.write("Fetching products from Odoo...")
|
||||
products = self._fetch_products_from_odoo(odoo_url)
|
||||
if not products:
|
||||
self.stdout.write(self.style.ERROR("No products found in Odoo. Cannot create offers."))
|
||||
return
|
||||
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}'")
|
||||
|
||||
# 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.WARNING(
|
||||
"No African hubs found. Using default locations."
|
||||
))
|
||||
hubs = self._default_african_hubs()
|
||||
|
||||
self.stdout.write(f"Found {len(hubs)} African hubs")
|
||||
|
||||
# Create suppliers
|
||||
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:
|
||||
created_offers = self._create_offers_via_workflow(offers_count, hubs, products)
|
||||
else:
|
||||
created_offers = self._create_offers_direct(offers_count, hubs, products)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created {len(created_offers)} offers"))
|
||||
|
||||
def _fetch_products_from_odoo(self, odoo_url: 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", # database
|
||||
2, # uid (admin)
|
||||
"admin", # 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"
|
||||
products.append((p["name"], category_name, p["uuid"]))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f"Failed to fetch products from Odoo: {e}"))
|
||||
|
||||
return products
|
||||
|
||||
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)
|
||||
|
||||
profile = SupplierProfile.objects.create(
|
||||
uuid=str(uuid.uuid4()),
|
||||
team_uuid=str(uuid.uuid4()),
|
||||
name=f"Cocoa Supplier {idx + 1} ({country})",
|
||||
description=f"Premium cocoa supplier from {country}. Specializing in high-quality cocoa beans.",
|
||||
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 _create_offers_via_workflow(self, count: int, hubs: list, products: list) -> 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 = random.choice(hubs)
|
||||
product_name, category_name, product_uuid = 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=self._rand_decimal(2000, 4000, 2), # Cocoa ~$2000-4000/ton
|
||||
currency="USD",
|
||||
description=f"High quality {product_name} from {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}"))
|
||||
|
||||
return created
|
||||
|
||||
def _create_offers_direct(self, count: int, hubs: list, products: list) -> 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 = random.choice(hubs)
|
||||
product_name, category_name, product_uuid = 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=self._rand_decimal(2000, 4000, 2),
|
||||
currency="USD",
|
||||
description=f"High quality {product_name} from {hub['country']}",
|
||||
)
|
||||
created.append(offer)
|
||||
|
||||
return created
|
||||
|
||||
def _get_random_african_country(self) -> tuple:
|
||||
"""Get random African country name and code"""
|
||||
country, code, _, _ = random.choice(AFRICAN_COUNTRIES)
|
||||
return country, code
|
||||
|
||||
def _get_country_coords(self, country: str) -> tuple:
|
||||
"""Get default coordinates for a country"""
|
||||
for name, code, lat, lng in AFRICAN_COUNTRIES:
|
||||
if name == country:
|
||||
return lat, lng
|
||||
return 6.0, 0.0 # Default: Gulf of Guinea
|
||||
|
||||
def _rand_decimal(self, low: int, high: int, places: int) -> Decimal:
|
||||
value = random.uniform(low, high)
|
||||
quantize_str = "1." + "0" * places
|
||||
return Decimal(str(value)).quantize(Decimal(quantize_str))
|
||||
56
offers/migrations/0001_initial.py
Normal file
56
offers/migrations/0001_initial.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
18
offers/migrations/0003_offer_workflow_status.py
Normal file
18
offers/migrations/0003_offer_workflow_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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=''),
|
||||
),
|
||||
]
|
||||
0
offers/migrations/__init__.py
Normal file
0
offers/migrations/__init__.py
Normal file
64
offers/models.py
Normal file
64
offers/models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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})"
|
||||
103
offers/services.py
Normal file
103
offers/services.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Сервис для создания офферов через 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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())
|
||||
|
||||
workflow_id, run_id = start_offer_workflow(
|
||||
offer_uuid=offer_uuid,
|
||||
team_uuid=data.team_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]
|
||||
"""
|
||||
workflow_id, run_id = start_offer_workflow(
|
||||
offer_uuid=offer.uuid,
|
||||
team_uuid=offer.team_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
|
||||
Reference in New Issue
Block a user