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é ("Togo", "TG", 6.1725, 1.2314), # Lomé
] ]
# Supplier name building blocks # Realistic supplier names (English, Africa-focused)
SUPPLIER_PREFIXES = [ SUPPLIER_NAMES = [
"Amber", "Delta", "Atlas", "Nova", "Summit", "Orion", "Cedar", "Prairie", "Cocoa Coast Exports", "Golden Savannah Trading", "Abidjan Agro Partners",
"Horizon", "Lumen", "Vertex", "Harbor", "Nile", "Savanna", "Marlin", "Volta River Commodities", "Lagos Harbor Supply", "Accra Prime Exports",
"Everest", "Cascade", "Cobalt", "Aurora", "Mosaic", "Meridian", "Granite", "Tema Logistics & Trading", "Sahel Harvest Group", "Nile Delta Commodities",
] "Gulf of Guinea Traders", "Kumasi Cocoa Collective", "Benin AgroLink",
SUPPLIER_SUFFIXES = [ "Douala Growth Partners", "Westbridge Commodities", "Ivory Gate Exporters",
"Agro", "Trade", "Commodities", "Exports", "Supply", "Foods", "Farms", "Ghana Frontier Trading", "Sunrise Agro Holdings", "Coastal Belt Supply",
"Harvest", "Producers", "Logistics", "Trading", "Cooperative", "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 # Fixed product catalog (10 items) with realistic prices per ton (USD)
# Format: (name, category, uuid) - populated by _fetch_products_from_odoo() 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): class Command(BaseCommand):
@@ -61,6 +101,12 @@ class Command(BaseCommand):
default=10, default=10,
help="How many distinct products to use (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( parser.add_argument(
"--clear", "--clear",
action="store_true", action="store_true",
@@ -83,6 +129,29 @@ class Command(BaseCommand):
default="http://odoo:8069", default="http://odoo:8069",
help="Odoo URL (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( parser.add_argument(
"--product", "--product",
type=str, type=str,
@@ -102,17 +171,27 @@ class Command(BaseCommand):
suppliers_count = max(0, options["suppliers"]) suppliers_count = max(0, options["suppliers"])
offers_count = max(0, options["offers"]) offers_count = max(0, options["offers"])
product_count = max(1, options["product_count"]) 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_workflow = not options["no_workflow"]
geo_url = options["geo_url"] geo_url = options["geo_url"]
odoo_url = options["odoo_url"] odoo_url = options["odoo_url"]
product_filter = options["product"] 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 # Fetch products from Odoo
self.stdout.write("Fetching 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: if not products:
self.stdout.write(self.style.ERROR("No products found in Odoo. Cannot create offers.")) self.stdout.write(self.style.WARNING("No products found in Odoo. Falling back to catalog only."))
return products = self._catalog_products()
self.stdout.write(f"Found {len(products)} products") self.stdout.write(f"Found {len(products)} products")
# Filter by product name if specified # Filter by product name if specified
@@ -148,12 +227,19 @@ class Command(BaseCommand):
# Create offers # Create offers
self.stdout.write(f"Creating {offers_count} offers (workflow={use_workflow})...") self.stdout.write(f"Creating {offers_count} offers (workflow={use_workflow})...")
if 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: 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")) 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""" """Fetch products from Odoo via JSON-RPC"""
products = [] products = []
try: try:
@@ -167,9 +253,9 @@ class Command(BaseCommand):
"service": "object", "service": "object",
"method": "execute_kw", "method": "execute_kw",
"args": [ "args": [
"odoo", # database odoo_db, # database
2, # uid (admin) odoo_user, # uid
"admin", # password odoo_password, # password
"products.product", # model "products.product", # model
"search_read", "search_read",
[[]], # domain (all products) [[]], # domain (all products)
@@ -185,12 +271,155 @@ class Command(BaseCommand):
result = data.get("result", []) result = data.get("result", [])
for p in result: for p in result:
category_name = p.get("category_id", [None, "Agriculture"])[1] if p.get("category_id") else "Agriculture" 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: except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to fetch products from Odoo: {e}")) self.stdout.write(self.style.WARNING(f"Failed to fetch products from Odoo: {e}"))
return products 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: def _fetch_african_hubs(self, geo_url: str) -> list:
"""Fetch African hubs from geo service via GraphQL. """Fetch African hubs from geo service via GraphQL.
@@ -302,7 +531,6 @@ class Command(BaseCommand):
def _create_suppliers(self, count: int, hubs: list) -> list: def _create_suppliers(self, count: int, hubs: list) -> list:
"""Create supplier profiles in African countries""" """Create supplier profiles in African countries"""
created = [] created = []
used_names = set()
for idx in range(count): for idx in range(count):
hub = random.choice(hubs) if hubs else None hub = random.choice(hubs) if hubs else None
country, country_code = self._get_random_african_country() country, country_code = self._get_random_african_country()
@@ -316,7 +544,7 @@ class Command(BaseCommand):
lat += random.uniform(-0.5, 0.5) lat += random.uniform(-0.5, 0.5)
lng += 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 = ( description = (
f"{name} is a reliable supplier based in {country}, " f"{name} is a reliable supplier based in {country}, "
"focused on consistent quality and transparent logistics." "focused on consistent quality and transparent logistics."
@@ -338,21 +566,41 @@ class Command(BaseCommand):
created.append(profile) created.append(profile)
return created return created
def _generate_supplier_name(self, country: str, used_names: set) -> str: def _generate_supplier_name(self, index: int) -> str:
"""Generate a readable supplier name with low collision probability.""" """Pick a realistic supplier name; fall back if list is exhausted."""
for _ in range(50): if index < len(SUPPLIER_NAMES):
prefix = random.choice(SUPPLIER_PREFIXES) return SUPPLIER_NAMES[index]
suffix = random.choice(SUPPLIER_SUFFIXES) return f"{random.choice(SUPPLIER_NAMES)} Group"
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 _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)""" """Create offers via Temporal workflow (syncs to graph)"""
created = [] created = []
suppliers = list(SupplierProfile.objects.all()) suppliers = list(SupplierProfile.objects.all())
@@ -363,8 +611,8 @@ class Command(BaseCommand):
for idx in range(count): for idx in range(count):
supplier = random.choice(suppliers) supplier = random.choice(suppliers)
hub = random.choice(hubs) hub = self._pick_location(supplier, hubs, supplier_ratio)
product_name, category_name, product_uuid = random.choice(products) product_name, category_name, product_uuid, product_price = random.choice(products)
data = OfferData( data = OfferData(
team_uuid=supplier.team_uuid, team_uuid=supplier.team_uuid,
@@ -379,9 +627,9 @@ class Command(BaseCommand):
location_longitude=hub["longitude"], location_longitude=hub["longitude"],
quantity=self._rand_decimal(10, 500, 2), quantity=self._rand_decimal(10, 500, 2),
unit="ton", unit="ton",
price_per_unit=self._rand_decimal(2000, 4000, 2), # Cocoa ~$2000-4000/ton price_per_unit=product_price,
currency="USD", currency="USD",
description=f"High quality {product_name} from {hub['country']}", description=f"{product_name} available from {hub['name']} in {hub['country']}",
) )
try: try:
@@ -393,7 +641,7 @@ class Command(BaseCommand):
return created 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)""" """Create offers directly in DB (no workflow, no graph sync)"""
created = [] created = []
suppliers = list(SupplierProfile.objects.all()) suppliers = list(SupplierProfile.objects.all())
@@ -404,8 +652,8 @@ class Command(BaseCommand):
for idx in range(count): for idx in range(count):
supplier = random.choice(suppliers) supplier = random.choice(suppliers)
hub = random.choice(hubs) hub = self._pick_location(supplier, hubs, supplier_ratio)
product_name, category_name, product_uuid = random.choice(products) product_name, category_name, product_uuid, product_price = random.choice(products)
offer = Offer.objects.create( offer = Offer.objects.create(
uuid=str(uuid.uuid4()), uuid=str(uuid.uuid4()),
@@ -423,9 +671,9 @@ class Command(BaseCommand):
category_name=category_name, category_name=category_name,
quantity=self._rand_decimal(10, 500, 2), quantity=self._rand_decimal(10, 500, 2),
unit="ton", unit="ton",
price_per_unit=self._rand_decimal(2000, 4000, 2), price_per_unit=product_price,
currency="USD", 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) created.append(offer)