diff --git a/offers/management/commands/seed_exchange.py b/offers/management/commands/seed_exchange.py index 9408b9c..0a4e0af 100644 --- a/offers/management/commands/seed_exchange.py +++ b/offers/management/commands/seed_exchange.py @@ -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)