Make exchange seed data realistic
All checks were successful
Build Docker Image / build (push) Successful in 1m33s

This commit is contained in:
Ruslan Bakiev
2026-02-04 15:47:07 +07:00
parent 2e724a58c1
commit dd5fdef0d4

View File

@@ -24,19 +24,59 @@ AFRICAN_COUNTRIES = [
("Togo", "TG", 6.1725, 1.2314), # Lomé
]
# Supplier name building blocks
SUPPLIER_PREFIXES = [
"Amber", "Delta", "Atlas", "Nova", "Summit", "Orion", "Cedar", "Prairie",
"Horizon", "Lumen", "Vertex", "Harbor", "Nile", "Savanna", "Marlin",
"Everest", "Cascade", "Cobalt", "Aurora", "Mosaic", "Meridian", "Granite",
]
SUPPLIER_SUFFIXES = [
"Agro", "Trade", "Commodities", "Exports", "Supply", "Foods", "Farms",
"Harvest", "Producers", "Logistics", "Trading", "Cooperative",
# 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",
]
# Products will be fetched from Odoo at runtime
# Format: (name, category, uuid) - populated by _fetch_products_from_odoo()
# 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):
@@ -61,6 +101,12 @@ class Command(BaseCommand):
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",
@@ -83,6 +129,29 @@ class Command(BaseCommand):
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,
@@ -102,17 +171,27 @@ class Command(BaseCommand):
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"]
geo_url = options["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"]
# Fetch products from Odoo
self.stdout.write("Fetching products from Odoo...")
products = self._fetch_products_from_odoo(odoo_url)
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.ERROR("No products found in Odoo. Cannot create offers."))
return
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
@@ -148,12 +227,19 @@ class Command(BaseCommand):
# 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)
created_offers = self._create_offers_via_workflow(
offers_count, hubs, products, supplier_location_ratio
)
else:
created_offers = self._create_offers_direct(offers_count, hubs, products)
created_offers = self._create_offers_direct(
offers_count, hubs, products, supplier_location_ratio
)
self.stdout.write(self.style.SUCCESS(f"Created {len(created_offers)} offers"))
def _fetch_products_from_odoo(self, odoo_url: str) -> list:
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:
@@ -167,9 +253,9 @@ class Command(BaseCommand):
"service": "object",
"method": "execute_kw",
"args": [
"odoo", # database
2, # uid (admin)
"admin", # password
odoo_db, # database
odoo_user, # uid
odoo_password, # password
"products.product", # model
"search_read",
[[]], # domain (all products)
@@ -185,12 +271,155 @@ class Command(BaseCommand):
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"]))
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.
@@ -302,7 +531,6 @@ class Command(BaseCommand):
def _create_suppliers(self, count: int, hubs: list) -> list:
"""Create supplier profiles in African countries"""
created = []
used_names = set()
for idx in range(count):
hub = random.choice(hubs) if hubs else None
country, country_code = self._get_random_african_country()
@@ -316,7 +544,7 @@ class Command(BaseCommand):
lat += random.uniform(-0.5, 0.5)
lng += random.uniform(-0.5, 0.5)
name = self._generate_supplier_name(country, used_names)
name = self._generate_supplier_name(idx)
description = (
f"{name} is a reliable supplier based in {country}, "
"focused on consistent quality and transparent logistics."
@@ -338,21 +566,41 @@ class Command(BaseCommand):
created.append(profile)
return created
def _generate_supplier_name(self, country: str, used_names: set) -> str:
"""Generate a readable supplier name with low collision probability."""
for _ in range(50):
prefix = random.choice(SUPPLIER_PREFIXES)
suffix = random.choice(SUPPLIER_SUFFIXES)
name = f\"{prefix} {suffix}\"
if name not in used_names:
used_names.add(name)
return name
# Fallback with country tag to avoid collisions
name = f\"{random.choice(SUPPLIER_PREFIXES)} {random.choice(SUPPLIER_SUFFIXES)} {country}\"
used_names.add(name)
return name
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 _create_offers_via_workflow(self, count: int, hubs: list, products: list) -> list:
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) -> list:
"""Create offers via Temporal workflow (syncs to graph)"""
created = []
suppliers = list(SupplierProfile.objects.all())
@@ -363,8 +611,8 @@ class Command(BaseCommand):
for idx in range(count):
supplier = random.choice(suppliers)
hub = random.choice(hubs)
product_name, category_name, product_uuid = random.choice(products)
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,
@@ -379,9 +627,9 @@ class Command(BaseCommand):
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
price_per_unit=product_price,
currency="USD",
description=f"High quality {product_name} from {hub['country']}",
description=f"{product_name} available from {hub['name']} in {hub['country']}",
)
try:
@@ -393,7 +641,7 @@ class Command(BaseCommand):
return created
def _create_offers_direct(self, count: int, hubs: list, products: list) -> list:
def _create_offers_direct(self, count: int, hubs: list, products: list, supplier_ratio: float) -> list:
"""Create offers directly in DB (no workflow, no graph sync)"""
created = []
suppliers = list(SupplierProfile.objects.all())
@@ -404,8 +652,8 @@ class Command(BaseCommand):
for idx in range(count):
supplier = random.choice(suppliers)
hub = random.choice(hubs)
product_name, category_name, product_uuid = random.choice(products)
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()),
@@ -423,9 +671,9 @@ class Command(BaseCommand):
category_name=category_name,
quantity=self._rand_decimal(10, 500, 2),
unit="ton",
price_per_unit=self._rand_decimal(2000, 4000, 2),
price_per_unit=product_price,
currency="USD",
description=f"High quality {product_name} from {hub['country']}",
description=f"{product_name} available from {hub['name']} in {hub['country']}",
)
created.append(offer)