Make exchange seed data realistic
All checks were successful
Build Docker Image / build (push) Successful in 1m33s
All checks were successful
Build Docker Image / build (push) Successful in 1m33s
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user