diff --git a/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md new file mode 100644 index 000000000..d3eb41fe8 --- /dev/null +++ b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,718 @@ +# Technical Documentation: Shopify Null Product ID Handling + +## Document Information +- **Version:** 1.0 +- **Date:** December 16, 2025 +- **Branch:** `fix/shopify-null-product-id-fallback-mapping` +- **Commit:** `eaf8d8a` +- **Author:** Development Team + +--- + +## Table of Contents +1. [Problem Statement](#problem-statement) +2. [Solution Overview](#solution-overview) +3. [Files Modified](#files-modified) +4. [Detailed Code Changes](#detailed-code-changes) +5. [Function Reference](#function-reference) +6. [Data Flow](#data-flow) +7. [Testing Scenarios](#testing-scenarios) +8. [Dependencies](#dependencies) + +--- + +## Problem Statement + +### Issue +Shopify orders containing line items without a `product_id` (such as tips, samples, rush fees, adjustments) were failing to sync to ERPNext with the error: + +``` +expected String to be a id +``` + +### Root Causes +1. **Null Product ID Items**: Shopify allows line items without `product_id` for non-product charges (tips, samples, fees, adjustments) +2. **Missing Fallback Logic**: The system attempted to sync these items as regular products, causing API errors +3. **Early Filtering**: Items with null `product_id` were filtered out by `product_exists` check before reaching item mapping logic +4. **Duplicate Item Errors**: Existing items with missing Ecommerce Item link records caused duplicate errors that crashed the sync process + +### Impact +- Orders with tips, samples, or fees failed to sync completely +- Sales Orders were not created for affected orders +- Manual intervention required for each failed order + +--- + +## Solution Overview + +### Approach +Implemented a multi-layered solution: + +1. **Intelligent Fallback Mapping**: Map null `product_id` items to pre-configured fallback items based on line item title keywords +2. **Early Processing**: Process null `product_id` items before `product_exists` validation +3. **Error Handling**: Add comprehensive error handling to prevent sync failures +4. **Enhanced Matching**: Improve item matching to handle both SKU and product_id based item codes + +### Fallback Item Mapping +| Line Item Title Contains | Mapped To ERPNext Item | +|-------------------------|------------------------| +| "tip" | `SHOPIFY-TIP` | +| "sample" | `SHOPIFY-SAMPLE` | +| "rush", "rush order", "rush fee" | `SHOPIFY-RUSH-FEE` | +| "adjustment", "price adjustment" | `SHOPIFY-ADJUSTMENT` | +| Any other text or empty | `SHOPIFY-MISC` | + +**Note:** These fallback items must exist in ERPNext before deployment. + +--- + +## Files Modified + +### Summary +- **2 files modified** +- **~95 lines added** +- **~15 lines modified** + +### File List +1. `ecommerce_integrations/shopify/product.py` +2. `ecommerce_integrations/shopify/order.py` + +--- + +## Detailed Code Changes + +### File 1: `ecommerce_integrations/shopify/product.py` + +#### Change 1: New Function - `get_shopify_fallback_item()` + +**Location:** Lines 22-46 (after imports, before `ShopifyProduct` class) + +**Purpose:** Maps Shopify line item titles to appropriate ERPNext fallback items + +**Code Added:** +```python +def get_shopify_fallback_item(title): + """Map Shopify line items without product_id to appropriate fallback items. + + Args: + title (str): Line item title from Shopify + + Returns: + str: ERPNext Item code + """ + if not title: + return "SHOPIFY-MISC" + + title_lower = title.lower() + + # Map based on title keywords + if "tip" in title_lower: + return "SHOPIFY-TIP" + elif "sample" in title_lower: + return "SHOPIFY-SAMPLE" + elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower: + return "SHOPIFY-RUSH-FEE" + elif "adjustment" in title_lower or "price adjustment" in title_lower: + return "SHOPIFY-ADJUSTMENT" + else: + return "SHOPIFY-MISC" +``` + +**Logic:** +- Case-insensitive keyword matching +- Priority order: tip → sample → rush → adjustment → misc +- Returns default `SHOPIFY-MISC` for empty titles or unmatched items + +--- + +#### Change 2: Enhanced Function - `_match_sku_and_link_item()` + +**Location:** Lines 301-351 + +**Purpose:** Match existing ERPNext items by both SKU and product_id to prevent duplicate creation + +**Before:** +```python +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if not sku or variant_of or has_variant: + return False + + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + return False +``` + +**After:** +```python +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code or product_id. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if variant_of or has_variant: + return False + + # Try matching by SKU first + if sku: + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + pass + + # Also try matching by product_id as item_code + item_name = frappe.db.get_value("Item", {"item_code": product_id}) + if item_name: + try: + ecommerce_item = frappe.get_doc({...}) + ecommerce_item.insert() + return True + except Exception: + return False + + return False +``` + +**Key Changes:** +1. Removed `if not sku` early return - now continues even without SKU +2. Added two-stage matching: + - **Stage 1:** Match by SKU (original logic) + - **Stage 2:** Match by product_id as item_code (new) +3. Better error handling with `pass` in Stage 1 to allow Stage 2 attempt + +**Why:** Items created with product_id as item_code weren't found by SKU matching, causing duplicate errors. + +--- + +#### Change 3: Enhanced Function - `create_items_if_not_exist()` + +**Location:** Lines 354-376 + +**Purpose:** Skip syncing items with null product_id and add error handling + +**Before:** +```python +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" + for item in order.get("line_items", []): + product_id = item["product_id"] + variant_id = item.get("variant_id") + sku = item.get("sku") + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + + if not product.is_synced(): + product.sync_product() +``` + +**After:** +```python +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" + for item in order.get("line_items", []): + product_id = item.get("product_id") + variant_id = item.get("variant_id") + sku = item.get("sku") + + # Skip items with null product_id - mapped to fallback items + if not product_id: + continue + + try: + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + if not product.is_synced(): + product.sync_product() + except frappe.DuplicateEntryError: + frappe.logger().info(f"Item {product_id} already exists, skipping") + continue + except Exception as e: + frappe.logger().error(f"Error syncing item {product_id}: {str(e)}") + if "IntegrityError" not in str(e): + raise + continue +``` + +**Key Changes:** +1. Changed `item["product_id"]` to `item.get("product_id")` - handles null values +2. Added null check: `if not product_id: continue` - skips null product_id items +3. Wrapped sync logic in try-except block +4. Catches `DuplicateEntryError` - logs and continues +5. Catches `IntegrityError` - logs and continues (database constraint violations) +6. Re-raises other exceptions - real errors still propagate + +**Why:** Prevents sync crashes when duplicate items exist or database constraints fail. + +--- + +#### Change 4: Enhanced Function - `get_item_code()` + +**Location:** Lines 379-412 + +**Purpose:** Handle null product_id items by mapping to fallback items + +**Before:** +```python +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=shopify_item.get("product_id"), + variant_id=shopify_item.get("variant_id"), + sku=shopify_item.get("sku"), + ) + if item: + return item.item_code +``` + +**After:** +```python +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + product_id = shopify_item.get("product_id") + variant_id = shopify_item.get("variant_id") + sku = shopify_item.get("sku") + title = shopify_item.get("title", "") + + # Handle items without product_id (tips, samples, fees) + if not product_id: + fallback_item = get_shopify_fallback_item(title) + + frappe.logger().info( + f"Line item '{title}' has no product_id - mapped to: {fallback_item}" + ) + + if frappe.db.exists("Item", fallback_item): + return fallback_item + else: + frappe.throw( + f"Fallback item '{fallback_item}' not found for '{title}'" + ) + + # Original logic continues + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=product_id, + variant_id=variant_id, + sku=sku, + ) + if item: + return item.item_code +``` + +**Key Changes:** +1. Extract `product_id`, `variant_id`, `sku`, and `title` at function start +2. Added null `product_id` check at beginning +3. Call `get_shopify_fallback_item(title)` for mapping +4. Log the mapping for debugging +5. Verify fallback item exists in ERPNext +6. Return fallback item code or throw error if not found +7. Original logic continues for items with valid `product_id` + +**Why:** Provides intelligent mapping for non-product line items before they cause errors. + +--- + +### File 2: `ecommerce_integrations/shopify/order.py` + +#### Change 1: Enhanced Function - `get_order_items()` + +**Location:** Lines 139-194 + +**Purpose:** Process null product_id items before product_exists validation + +**Before:** +```python +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): + items = [] + all_product_exists = True + product_not_exists = [] + + for shopify_item in order_items: + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append({...}) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + items.append({...}) + else: + items = [] + + return items +``` + +**After:** +```python +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): + items = [] + all_product_exists = True + product_not_exists = [] + + for shopify_item in order_items: + product_id = shopify_item.get("product_id") + + # Handle items without product_id (tips, samples, fees) - skip product_exists check + if not product_id: + item_code = get_item_code(shopify_item) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append({...}) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + items.append({...}) + else: + items = [] + + return items +``` + +**Key Changes:** +1. Extract `product_id` at start of loop +2. Added null `product_id` check **before** `product_exists` check +3. For null `product_id`: + - Call `get_item_code()` which routes to fallback mapping + - Add item to list with proper structure + - Use `shopify_item.get("name") or shopify_item.get("title")` for item_name + - Use `"Nos"` as default stock_uom + - Continue to next item (skip `product_exists` validation) +4. Original logic preserved for items with valid `product_id` + +**Why:** Null `product_id` items were being filtered out by `product_exists` check before reaching the mapping logic. + +--- + +## Function Reference + +### New Functions + +#### `get_shopify_fallback_item(title: str) -> str` +- **File:** `product.py` +- **Line:** 22-46 +- **Parameters:** + - `title` (str): Line item title from Shopify order +- **Returns:** ERPNext Item code (str) +- **Description:** Maps line item titles to fallback items using keyword matching +- **Dependencies:** None +- **Side Effects:** None + +### Modified Functions + +#### `_match_sku_and_link_item()` +- **File:** `product.py` +- **Line:** 301-351 +- **Changes:** Two-stage matching (SKU + product_id) +- **Impact:** Prevents duplicate item creation errors + +#### `create_items_if_not_exist()` +- **File:** `product.py` +- **Line:** 354-376 +- **Changes:** Null check + error handling +- **Impact:** Skips null product_id items, handles duplicates gracefully + +#### `get_item_code()` +- **File:** `product.py` +- **Line:** 379-412 +- **Changes:** Null product_id handling with fallback mapping +- **Impact:** Maps non-product items to fallback items + +#### `get_order_items()` +- **File:** `order.py` +- **Line:** 139-194 +- **Changes:** Process null product_id items early +- **Impact:** Ensures null product_id items are included in Sales Orders + +--- + +## Data Flow + +### Order Sync Flow (Before) +``` +Shopify Order Received + ↓ +sync_sales_order() + ↓ +create_items_if_not_exist() + ↓ +For each line_item: + - product_id = item["product_id"] ← CRASH if null + - Try to sync product + ↓ +get_order_items() + ↓ +For each line_item: + - Check product_exists ← Filters out null product_id + - get_item_code() ← Never reached for null product_id + ↓ +Sales Order Created (incomplete) +``` + +### Order Sync Flow (After) +``` +Shopify Order Received + ↓ +sync_sales_order() + ↓ +create_items_if_not_exist() + ↓ +For each line_item: + - product_id = item.get("product_id") + - if not product_id: continue ← Skip syncing + - Try to sync product + - Catch duplicate errors → log & continue + ↓ +get_order_items() + ↓ +For each line_item: + - product_id = shopify_item.get("product_id") + - if not product_id: + - get_item_code() → get_shopify_fallback_item() + - Map to SHOPIFY-TIP/SAMPLE/etc. + - Add to items list + - continue + - Original logic for valid product_id + ↓ +Sales Order Created (complete with all items) +``` + +### Fallback Mapping Flow +``` +Line Item with null product_id + ↓ +get_item_code(shopify_item) + ↓ +Check: product_id is null? + ↓ YES +get_shopify_fallback_item(title) + ↓ +Keyword Matching: + - "tip" → SHOPIFY-TIP + - "sample" → SHOPIFY-SAMPLE + - "rush" → SHOPIFY-RUSH-FEE + - "adjustment" → SHOPIFY-ADJUSTMENT + - else → SHOPIFY-MISC + ↓ +Verify item exists in ERPNext + ↓ +Return item_code +``` + +--- + +## Testing Scenarios + +### Test Case 1: Order with Tip +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Tip" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-TIP + +**Test Command:** +```python +test_order_sync("43-31469-21") +``` + +### Test Case 2: Order with Sample +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Sample Fabric" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-SAMPLE + +### Test Case 3: Order with Rush Fee +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Rush Order Fee" (product_id: null) + +**Expected Output:** +- Sales Order with 2 line items +- Item 1: Actual product item +- Item 2: SHOPIFY-RUSH-FEE + +### Test Case 4: Order with Multiple Non-Product Items +**Input:** +- Line Item 1: Product (product_id: 12345) +- Line Item 2: "Tip" (product_id: null) +- Line Item 3: "Rush Fee" (product_id: null) +- Line Item 4: "Order Adjustment" (product_id: null) + +**Expected Output:** +- Sales Order with 4 line items +- All items mapped correctly + +### Test Case 5: Duplicate Item Handling +**Input:** +- Order with existing item (product_id: 8706562621682) +- Ecommerce Item link missing + +**Expected Output:** +- Item matched by product_id +- Ecommerce Item link created +- No duplicate error +- Order syncs successfully + +### Test Case 6: Missing Fallback Item +**Input:** +- Order with "Tip" (product_id: null) +- SHOPIFY-TIP item not found in ERPNext + +**Expected Output:** +- Error: "Fallback item 'SHOPIFY-TIP' not found for 'Tip'" +- Order sync fails with clear error message + +--- + +## Dependencies + +### Required ERPNext Items +The following items must exist in ERPNext before deployment: + +1. **SHOPIFY-TIP** + - Item Code: `SHOPIFY-TIP` + - Item Name: "Customer Tip" (or similar) + - Item Group: As per business requirements + - Stock UOM: "Nos" + +2. **SHOPIFY-SAMPLE** + - Item Code: `SHOPIFY-SAMPLE` + - Item Name: "Sample Item" (or similar) + +3. **SHOPIFY-RUSH-FEE** + - Item Code: `SHOPIFY-RUSH-FEE` + - Item Name: "Rush Order Fee" (or similar) + +4. **SHOPIFY-ADJUSTMENT** + - Item Code: `SHOPIFY-ADJUSTMENT` + - Item Name: "Order Adjustment" (or similar) + +5. **SHOPIFY-MISC** + - Item Code: `SHOPIFY-MISC` + - Item Name: "Miscellaneous Charge" (or similar) + +### Code Dependencies +- `frappe` - Core Frappe framework +- `ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item` - Ecommerce Item doctype +- `ecommerce_integrations.shopify.constants` - Module constants +- `ecommerce_integrations.shopify.utils` - Utility functions + +### No Breaking Changes +- All changes are backward compatible +- Existing functionality preserved +- Only adds new handling for edge cases + +--- + +## Deployment Checklist + +- [ ] Verify all 5 fallback items exist in ERPNext +- [ ] Test with sample orders containing tips/samples/fees +- [ ] Verify error handling works for duplicate items +- [ ] Check logs for fallback mapping messages +- [ ] Test orders with mixed product and non-product items +- [ ] Verify Sales Orders are created with correct line items +- [ ] Monitor Integration Log for any new errors + +--- + +## Rollback Procedure + +If issues occur, restore from backup files: + +```bash +cd apps/ecommerce_integrations/ecommerce_integrations/shopify +cp product.py.backup.YYYYMMDD_HHMM product.py +cp order.py.backup.YYYYMMDD_HHMM order.py +bench restart +``` + +--- + +## Support & Troubleshooting + +### Common Issues + +1. **"Fallback item 'SHOPIFY-XXX' not found"** + - **Solution:** Create the missing fallback item in ERPNext + +2. **Orders still failing with duplicate errors** + - **Solution:** Check if `_match_sku_and_link_item()` is finding existing items correctly + +3. **Null product_id items not appearing in Sales Order** + - **Solution:** Verify `get_order_items()` is processing null items before `product_exists` check + +### Logging + +All fallback mappings are logged: +``` +Line item 'Tip' has no product_id - mapped to: SHOPIFY-TIP +``` + +Check logs with: +```bash +bench --site [site] logs | grep -i "mapped to" +``` + +--- + +## Version History + +- **v1.0** (2025-12-16): Initial implementation + - Added fallback item mapping + - Enhanced error handling + - Improved item matching logic + +--- + +## Contact + +For questions or issues, refer to: +- GitHub Repository: `sahilvikas/ecommerce_integrations` +- Branch: `fix/shopify-null-product-id-fallback-mapping` +- Commit: `eaf8d8a` + diff --git a/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md new file mode 100644 index 000000000..8391391c4 --- /dev/null +++ b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md @@ -0,0 +1,902 @@ +# Technical Documentation: Shopify Order Update Webhook with Comprehensive Logging + +## Document Information +- **Version:** 1.0 +- **Date:** January 2025 +- **Branch:** `shopify-null-product-id-fallback-mapping` +- **Author:** Priyanshi + +--- + +## Table of Contents +1. [Problem Statement](#problem-statement) +2. [Solution Overview](#solution-overview) +3. [Files Modified](#files-modified) +4. [Detailed Code Changes](#detailed-code-changes) +5. [Function Reference](#function-reference) +6. [Data Flow](#data-flow) +7. [Testing Scenarios](#testing-scenarios) +8. [Dependencies](#dependencies) +9. [Deployment Checklist](#deployment-checklist) +10. [Rollback Procedure](#rollback-procedure) +11. [Support & Troubleshooting](#support--troubleshooting) + +--- + +## Problem Statement + +### Issue +When orders are updated in Shopify (amounts changed, items added/removed, status changed), there was no mechanism to: +- Track these updates in ERPNext +- Log comprehensive details about what changed +- Notify the OPS team about order modifications +- Maintain an audit trail of all order changes +- Capture all order references, primary keys, and amount details for communication purposes + +### Root Causes +1. **Missing Webhook Handler** — No webhook event handler for `orders/updated` event +2. **No Change Tracking** — System couldn't detect or log what changed in orders +3. **Incomplete Logging** — Existing logs didn't capture all necessary details (references, keys, amounts, changes) +4. **No Multi-Store Support** — Update webhook wasn't configured for Store 2 +5. **Limited Visibility** — OPS team had no way to see order updates without manual checking + +### Impact +- Order updates in Shopify were not tracked in ERPNext +- No audit trail for order modifications +- OPS team couldn't see what changed in orders +- Difficult to communicate order changes to stakeholders +- No way to detect discrepancies between Shopify and ERPNext + +--- + +## Solution Overview + +### Approach +Implemented a comprehensive solution: +1. **Webhook Event Registration** — Added `orders/updated` to webhook events for both stores +2. **Update Handler Function** — Created `update_sales_order()` to handle order updates +3. **Comprehensive Logging** — Logs all order references, primary keys, amount details, and change details +4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes +5. **Multi-Store Support** — Works seamlessly with both Store 1 and Store 2 +6. **Complete Data Extraction** — Captures all necessary information for OPS team communication + +### Key Features +- ✅ Automatic webhook registration for both stores +- ✅ Complete order reference tracking (Shopify IDs ↔ ERPNext documents) +- ✅ All primary keys captured (order, items, fulfillments, discounts) +- ✅ Detailed amount breakdowns (totals, line items, taxes, shipping) +- ✅ Change detection (amounts, items, status) +- ✅ Comprehensive log entries in Ecommerce Integration Log +- ✅ Support for new order creation if order doesn't exist + +--- + +## Files Modified + +### Summary +- **2 files modified** +- **~600 lines added** +- **2 lines modified** + +### File List +1. `ecommerce_integrations/shopify/constants.py` +2. `ecommerce_integrations/shopify/order.py` + +--- + +## Detailed Code Changes + +### File 1: `ecommerce_integrations/shopify/constants.py` + +#### Change 1: Add `orders/updated` to WEBHOOK_EVENTS + +**Location:** Line 17 + +**Purpose:** Register the order update webhook event + +**Before:** +```python +WEBHOOK_EVENTS = [ + "orders/create", + "orders/paid", + "orders/fulfilled", + "orders/cancelled", + "orders/partially_fulfilled", +] +``` + +**After:** +```python +WEBHOOK_EVENTS = [ + "orders/create", + "orders/paid", + "orders/fulfilled", + "orders/cancelled", + "orders/partially_fulfilled", + "orders/updated", # ← NEW +] +``` + +**Why:** Enables automatic webhook registration for order updates in both Store 1 and Store 2. + +--- + +#### Change 2: Add Event Mapping to EVENT_MAPPER + +**Location:** Line 26 + +**Purpose:** Map the webhook event to the handler function + +**Before:** +```python +EVENT_MAPPER = { + "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order", + "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice", + "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", + "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", +} +``` + +**After:** +```python +EVENT_MAPPER = { + "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order", + "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice", + "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", + "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", # ← NEW +} +``` + +**Why:** Routes the webhook event to the correct handler function when an order update is received. + +--- + +### File 2: `ecommerce_integrations/shopify/order.py` + +#### Change 1: New Function — `update_sales_order()` + +**Location:** Lines 657-1236 + +**Purpose:** Handle order updates from Shopify with comprehensive logging + +**Code Added:** +```python +def update_sales_order(payload, request_id=None, store_name=None): + """Handle order updates from Shopify with comprehensive logging. + + Logs all order references, change details, amount details, and primary keys. + Supports both Store 1 and Store 2. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("UPDATE-1", f""" +======================================== +ORDER UPDATE WEBHOOK RECEIVED +======================================== +Store: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +request_id: {request_id} +""", store_name) + + # Initialize comprehensive log data structure + log_data = { + "order_references": {}, + "change_details": {}, + "amount_details": {}, + "primary_keys": {}, + "line_items_details": [], + "status": "processing", + "store_name": store_name or "Store 1" + } + + try: + # ========== EXTRACT ALL PRIMARY KEYS ========== + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + customer_id = order.get("customer", {}).get("id") if order.get("customer") else None + + log_data["primary_keys"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "shopify_customer_id": customer_id, + "shopify_order_name": order.get("name", ""), + "shopify_order_token": order.get("token", ""), + "shopify_checkout_id": order.get("checkout_id"), + "shopify_checkout_token": order.get("checkout_token"), + } + + # Extract line item IDs + line_item_ids = [] + for item in order.get("line_items", []): + line_item_ids.append({ + "shopify_line_item_id": item.get("id"), + "shopify_product_id": item.get("product_id"), + "shopify_variant_id": item.get("variant_id"), + "shopify_sku": item.get("sku", ""), + }) + log_data["primary_keys"]["line_items"] = line_item_ids + + # Extract fulfillment IDs + fulfillment_ids = [] + for fulfillment in order.get("fulfillments", []): + fulfillment_ids.append({ + "shopify_fulfillment_id": fulfillment.get("id"), + "shopify_tracking_number": fulfillment.get("tracking_number"), + }) + log_data["primary_keys"]["fulfillments"] = fulfillment_ids + + # Extract discount code IDs + discount_codes = [] + for discount in order.get("discount_codes", []): + discount_codes.append({ + "shopify_discount_code": discount.get("code"), + "shopify_discount_type": discount.get("type"), + }) + log_data["primary_keys"]["discount_codes"] = discount_codes + + # ========== EXTRACT ALL ORDER REFERENCES ========== + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + customer_name = None + if customer_id: + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + + # Get related documents + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name") + + log_data["order_references"] = { + "shopify_order_id": order_id, + "shopify_order_number": order_number, + "erpnext_sales_order": sales_order_name, + "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""), + "erpnext_sales_invoice": sales_invoice, + "erpnext_delivery_notes": delivery_notes, + "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "", + "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "", + } + + # ========== EXTRACT AMOUNT DETAILS ========== + log_data["amount_details"] = { + "shopify_subtotal_price": flt(order.get("subtotal_price", 0)), + "shopify_total_tax": flt(order.get("total_tax", 0)), + "shopify_total_discounts": flt(order.get("total_discounts", 0)), + "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0, + "shopify_total_price": flt(order.get("total_price", 0)), + "shopify_total_price_usd": flt(order.get("total_price_usd", 0)), + "shopify_currency": order.get("currency", ""), + "shopify_current_total_price": flt(order.get("current_total_price", 0)), + "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)), + "shopify_current_total_tax": flt(order.get("current_total_tax", 0)), + "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)), + } + + # Line item amounts + line_item_amounts = [] + for item in order.get("line_items", []): + line_item_amounts.append({ + "shopify_line_item_id": item.get("id"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "total_discount": flt(_get_total_discount(item)), + "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)), + "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)), + }) + log_data["amount_details"]["line_items"] = line_item_amounts + + # Tax line amounts + tax_line_amounts = [] + for tax_line in order.get("tax_lines", []): + tax_line_amounts.append({ + "title": tax_line.get("title", ""), + "price": flt(tax_line.get("price", 0)), + "rate": flt(tax_line.get("rate", 0)), + }) + log_data["amount_details"]["tax_lines"] = tax_line_amounts + + # Shipping line amounts + shipping_line_amounts = [] + for shipping_line in order.get("shipping_lines", []): + shipping_line_amounts.append({ + "title": shipping_line.get("title", ""), + "price": flt(shipping_line.get("price", 0)), + "code": shipping_line.get("code", ""), + }) + log_data["amount_details"]["shipping_lines"] = shipping_line_amounts + + # ========== DETECT CHANGES ========== + if not sales_order_name: + # Order doesn't exist, create it + log_data["change_details"] = { + "action": "create_new_order", + "reason": "Order not found in ERPNext", + } + log_data["status"] = "creating" + + create_shopify_log( + status="Info", + message=f"Order {order_number} not found, creating new order", + request_data=order, + response_data=log_data + ) + sync_sales_order(payload, request_id, store_name=store_name) + return + + # Order exists, compare and detect changes + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + # Compare amounts + old_total = flt(sales_order.grand_total) + new_total = flt(order.get("total_price", 0)) + if old_total != new_total: + changes["grand_total"] = { + "old": old_total, + "new": new_total, + "difference": new_total - old_total + } + + old_subtotal = flt(sales_order.total) + new_subtotal = flt(order.get("subtotal_price", 0)) + if old_subtotal != new_subtotal: + changes["subtotal"] = { + "old": old_subtotal, + "new": new_subtotal, + "difference": new_subtotal - old_subtotal + } + + # Compare order status + old_status = sales_order.get(ORDER_STATUS_FIELD) or "" + new_status = order.get("financial_status", "") + if old_status != new_status: + changes["financial_status"] = { + "old": old_status, + "new": new_status + } + + old_fulfillment_status = order.get("fulfillment_status") + new_fulfillment_status = order.get("fulfillment_status", "") + if old_fulfillment_status != new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status or "unfulfilled" + } + + # Compare line items + old_items_count = len(sales_order.items) + new_items_count = len(order.get("line_items", [])) + if old_items_count != new_items_count: + changes["line_items_count"] = { + "old": old_items_count, + "new": new_items_count + } + + # Compare line items in detail + line_item_changes = [] + shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])} + + # Get existing line item IDs from Sales Order + existing_shopify_ids = set() + for so_item in sales_order.items: + shopify_item_id = str(so_item.get("shopify_line_item_id", "")) + if shopify_item_id and shopify_item_id in shopify_items_map: + shopify_item = shopify_items_map[shopify_item_id] + item_changes = {} + + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(shopify_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + if item_changes: + line_item_changes.append({ + "item_code": so_item.item_code, + "shopify_line_item_id": shopify_item_id, + "changes": item_changes + }) + + existing_shopify_ids.add(shopify_item_id) + + # Check for new items + for shopify_item in order.get("line_items", []): + if str(shopify_item.get("id")) not in existing_shopify_ids: + line_item_changes.append({ + "shopify_line_item_id": str(shopify_item.get("id")), + "shopify_product_id": shopify_item.get("product_id"), + "shopify_variant_id": shopify_item.get("variant_id"), + "title": shopify_item.get("title"), + "action": "item_added" + }) + + # Build change details + log_data["change_details"] = { + "action": "order_updated", + "changes_detected": len(changes) > 0 or len(line_item_changes) > 0, + "amount_changes": { + "subtotal_changed": "subtotal" in changes, + "old_subtotal": old_subtotal, + "new_subtotal": new_subtotal, + "total_changed": "grand_total" in changes, + "old_total": old_total, + "new_total": new_total, + } if changes else {}, + "item_changes": { + "items_added": len([c for c in line_item_changes if c.get("action") == "item_added"]), + "items_removed": 0, # Can be enhanced + "items_modified": len([c for c in line_item_changes if "changes" in c]), + "line_item_changes": line_item_changes + }, + "status_changes": { + "financial_status_changed": "financial_status" in changes, + "old_financial_status": old_status, + "new_financial_status": new_status, + "fulfillment_status_changed": "fulfillment_status" in changes, + "old_fulfillment_status": old_fulfillment_status or "unfulfilled", + "new_fulfillment_status": new_fulfillment_status or "unfulfilled", + } if changes else {} + } + + # Extract line items details + for item in order.get("line_items", []): + log_data["line_items_details"].append({ + "shopify_line_item_id": item.get("id"), + "title": item.get("title"), + "quantity": cint(item.get("quantity", 0)), + "price": flt(item.get("price", 0)), + "sku": item.get("sku", ""), + "product_id": item.get("product_id"), + "variant_id": item.get("variant_id"), + }) + + log_data["status"] = "success" + + # Check if order is cancelled + if order.get("cancelled_at"): + log_data["change_details"]["action"] = "order_cancelled" + log_data["change_details"]["cancelled_at"] = order.get("cancelled_at") + log_data["change_details"]["cancel_reason"] = order.get("cancel_reason") + + create_shopify_log( + status="Info", + message=f"Order {order_number} was cancelled in Shopify", + request_data=order, + response_data=log_data + ) + else: + create_shopify_log( + status="Success", + message=f"Order {order_number} updated successfully", + request_data=order, + response_data=log_data + ) + + except Exception as e: + log_data["status"] = "error" + log_data["error"] = str(e) + log_data["traceback"] = traceback.format_exc() + + log_store2("UPDATE-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) + + create_shopify_log( + status="Error", + message=f"Error processing order update: {str(e)}", + request_data=order, + response_data=log_data, + exception=e + ) +``` + +**Key Features:** +1. **Primary Keys Extraction** — Captures all Shopify IDs (order, items, fulfillments, discounts) +2. **Order References** — Links Shopify orders to ERPNext documents (Sales Order, Customer, Invoice, Delivery Notes) +3. **Amount Details** — Complete financial breakdown (totals, line items, taxes, shipping) +4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes +5. **Line Items Details** — Complete item information for all line items +6. **Multi-Store Support** — Handles both Store 1 and Store 2 +7. **Error Handling** — Comprehensive error logging with traceback +8. **New Order Creation** — Creates new order if it doesn't exist in ERPNext + +**Why:** Provides complete visibility into order updates, enabling OPS team to track changes, communicate updates, and maintain audit trail. + +--- + +## Function Reference + +### New Functions + +#### `update_sales_order(payload, request_id=None, store_name=None)` +- **File:** `order.py` +- **Line:** 657-1236 +- **Parameters:** + - `payload` (dict): Shopify order data from webhook + - `request_id` (str, optional): Unique request identifier + - `store_name` (str, optional): Store name ("Store 2" or None for Store 1) +- **Returns:** None (creates log entry) +- **Description:** Handles order updates from Shopify with comprehensive logging +- **Dependencies:** + - `create_shopify_log()` from `utils.py` + - `sync_sales_order()` from `order.py` + - `log_store2()` for Store 2 debugging +- **Side Effects:** + - Creates log entry in Ecommerce Integration Log + - May create new Sales Order if order doesn't exist + +--- + +## Data Flow + +### Order Update Webhook Flow + +``` +1. Order Updated in Shopify + ↓ +2. Shopify Sends Webhook to ERPNext + ↓ +3. connection.py: store_request_data() + - Receives webhook + - Determines store (Store 1 or Store 2) from X-Shopify-Shop-Domain header + - Creates initial log (status: "Queued") + ↓ +4. connection.py: process_request() + - Enqueues job with store_name parameter + ↓ +5. order.py: update_sales_order() + - Extracts primary keys (order IDs, item IDs, fulfillment IDs) + - Extracts order references (Shopify ↔ ERPNext document links) + - Extracts amount details (totals, line items, taxes, shipping) + - Detects changes (compares Shopify with ERPNext) + - Extracts line items details + ↓ +6. Creates Final Log Entry + - Status: "Success", "Info", or "Error" + - Stores all data in response_data field + ↓ +7. Log Available in ERPNext + - View in Ecommerce Integration Log + - Use for notifications + - Use for OPS team communication +``` + +### Change Detection Flow + +``` +1. Get Shopify Order Data + ↓ +2. Find ERPNext Sales Order by shopify_order_id + ↓ +3. If Order Not Found: + - Set action: "create_new_order" + - Call sync_sales_order() to create new order + - Return + ↓ +4. If Order Found: + - Compare amounts (subtotal, total, tax, discounts) + - Compare line items (quantity, rate, added, removed) + - Compare status (financial, fulfillment) + ↓ +5. Build Change Details + - Store before/after values + - Calculate differences + - Identify what changed + ↓ +6. Create Log Entry + - All data stored in response_data + - Status based on outcome +``` + +### Multi-Store Flow + +``` +Store 1 Webhook: + ↓ +connection.py detects Store 1 + ↓ +process_request() with store_name=None + ↓ +update_sales_order() with store_name=None + ↓ +Logs with store_name="Store 1" + +Store 2 Webhook: + ↓ +connection.py detects Store 2 + ↓ +process_request() with store_name="Store 2" + ↓ +update_sales_order() with store_name="Store 2" + ↓ +Sets frappe.local.shopify_store_name + ↓ +Uses log_store2() for debugging + ↓ +Logs with store_name="Store 2" +``` + +--- + +## Testing Scenarios + +### Test Case 1: Order Amount Updated +**Input:** +- Existing order in ERPNext +- Order total changed from $100.00 to $120.00 in Shopify + +**Expected Output:** +- Log entry created with status "Success" +- `change_details.amount_changes.total_changed = true` +- `change_details.amount_changes.old_total = 100.00` +- `change_details.amount_changes.new_total = 120.00` +- All order references populated +- All primary keys captured + +**Test Command:** +```python +# Update order in Shopify, then check logs +logs = frappe.get_all( + "Ecommerce Integration Log", + filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"}, + order_by="creation desc", + limit=1 +) +``` + +### Test Case 2: Order Item Added +**Input:** +- Existing order with 2 items +- New item added in Shopify (now 3 items) + +**Expected Output:** +- Log entry created +- `change_details.item_changes.items_added = 1` +- `change_details.item_changes.line_item_changes` contains new item +- Line items details includes all 3 items + +### Test Case 3: Order Item Quantity Changed +**Input:** +- Existing order with item quantity = 2 +- Quantity changed to 3 in Shopify + +**Expected Output:** +- Log entry created +- `change_details.item_changes.items_modified = 1` +- `change_details.item_changes.line_item_changes[0].changes.quantity.old = 2` +- `change_details.item_changes.line_item_changes[0].changes.quantity.new = 3` + +### Test Case 4: Order Status Changed +**Input:** +- Existing order with financial_status = "pending" +- Status changed to "paid" in Shopify + +**Expected Output:** +- Log entry created +- `change_details.status_changes.financial_status_changed = true` +- `change_details.status_changes.old_financial_status = "pending"` +- `change_details.status_changes.new_financial_status = "paid"` + +### Test Case 5: Order Not Found (New Order) +**Input:** +- Order updated in Shopify +- Order doesn't exist in ERPNext + +**Expected Output:** +- Log entry created with status "Info" +- `change_details.action = "create_new_order"` +- `change_details.reason = "Order not found in ERPNext"` +- New Sales Order created via `sync_sales_order()` + +### Test Case 6: Order Cancelled +**Input:** +- Existing order +- Order cancelled in Shopify + +**Expected Output:** +- Log entry created with status "Info" +- `change_details.action = "order_cancelled"` +- `change_details.cancelled_at` populated +- `change_details.cancel_reason` populated + +### Test Case 7: Multi-Store Update +**Input:** +- Order updated in Store 1 +- Order updated in Store 2 + +**Expected Output:** +- Two separate log entries created +- Store 1 log has `store_name = "Store 1"` +- Store 2 log has `store_name = "Store 2"` +- Both logs contain complete data + +### Test Case 8: Error Handling +**Input:** +- Invalid order data or processing error + +**Expected Output:** +- Log entry created with status "Error" +- `response_data.status = "error"` +- `response_data.error` contains error message +- `response_data.traceback` contains full traceback + +--- + +## Dependencies + +### Code Dependencies +- `frappe` — Core Frappe framework +- `ecommerce_integrations.shopify.constants` — Module constants (ORDER_ID_FIELD, etc.) +- `ecommerce_integrations.shopify.utils` — Utility functions (create_shopify_log, log_store2) +- `ecommerce_integrations.shopify.order` — Order sync functions (sync_sales_order) + +### System Dependencies +- **Ecommerce Integration Log** doctype must exist +- **Sales Order** doctype with `shopify_order_id` custom field +- **Customer** doctype with `shopify_customer_id` custom field +- Webhook endpoint must be accessible from Shopify + +### No Breaking Changes +- All changes are backward compatible +- Existing functionality preserved +- Only adds new webhook handler +- Doesn't modify existing order sync logic + +--- + +## Deployment Checklist + +- [ ] Verify `orders/updated` is in `WEBHOOK_EVENTS` in `constants.py` +- [ ] Verify event mapping exists in `EVENT_MAPPER` in `constants.py` +- [ ] Verify `update_sales_order()` function exists in `order.py` +- [ ] Test webhook registration for Store 1 +- [ ] Test webhook registration for Store 2 (if enabled) +- [ ] Test order update webhook with sample order +- [ ] Verify log entry is created in Ecommerce Integration Log +- [ ] Verify all data is captured (references, keys, amounts, changes) +- [ ] Test change detection (amount, item, status changes) +- [ ] Test new order creation scenario +- [ ] Test error handling +- [ ] Verify multi-store support works correctly +- [ ] Monitor logs for any errors after deployment + +--- + +## Rollback Procedure + +If issues occur, restore from backup files: + +```bash +cd apps/ecommerce_integrations/ecommerce_integrations/shopify +cp constants.py.backup.YYYYMMDD_HHMM constants.py +cp order.py.backup.YYYYMMDD_HHMM order.py +bench restart +``` + +**Note:** After rollback, webhooks will need to be re-registered to remove `orders/updated` event. + +--- + +## Support & Troubleshooting + +### Common Issues + +#### 1. "Webhook not receiving updates" +**Symptoms:** +- No log entries created +- Webhook not triggered + +**Solutions:** +1. Check webhook registration: + - Go to: `Shopify Setting` + - Click "Register Webhooks" + - Verify `orders/updated` is registered +2. Check Shopify webhook settings: + - Go to Shopify Admin → Settings → Notifications + - Verify webhook URL is correct + - Verify webhook is active +3. Check logs: + - Check `connection.py` logs for webhook receipt + - Check for errors in `Ecommerce Integration Log` + +#### 2. "Logs not showing all data" +**Symptoms:** +- Log entry created but `response_data` is empty or incomplete + +**Solutions:** +1. Check function execution: + - Verify `update_sales_order()` is being called + - Check for errors in log entry +2. Check data extraction: + - Verify order payload has expected fields + - Check Store 2 logs if applicable +3. Check log creation: + - Verify `create_shopify_log()` is called + - Check `response_data` parameter is passed + +#### 3. "Change detection not working" +**Symptoms:** +- Changes made but not detected +- `change_details` shows no changes + +**Solutions:** +1. Check Sales Order exists: + - Verify Sales Order is found by Shopify Order ID + - Check custom field `shopify_order_id` is set +2. Check comparison logic: + - Verify amounts are being compared correctly + - Check for data type mismatches +3. Check log data: + - Review `response_data` in log entry + - Verify all fields are populated + +#### 4. "Store 2 not working" +**Symptoms:** +- Store 1 works but Store 2 doesn't + +**Solutions:** +1. Check Store 2 configuration: + - Verify `enable_store_2` is checked + - Verify Store 2 credentials are set +2. Check webhook registration: + - Verify webhooks are registered for Store 2 + - Check webhook URL includes store identifier +3. Check logs: + - Check Store 2 specific logs + - Verify `store_name` parameter is passed correctly + +### Logging + +All order updates are logged in `Ecommerce Integration Log`: +- **Method:** `ecommerce_integrations.shopify.order.update_sales_order` +- **Status:** "Success", "Info", or "Error" +- **Request Data:** Full Shopify order payload +- **Response Data:** Complete log data structure + +Check logs with: +```bash +bench --site [site] console +``` + +```python +logs = frappe.get_all( + "Ecommerce Integration Log", + filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"}, + fields=["name", "creation", "status", "response_data"] +) +``` + +--- + +## Version History + +- **v1.0** (January 2025): Initial implementation + - Added `orders/updated` webhook event + - Created `update_sales_order()` function + - Implemented comprehensive logging + - Added change detection + - Multi-store support + +--- + +## Contact + +For questions or issues, refer to: +- **Author:** Priyanshi +- **Branch:** `shopify-null-product-id-fallback-mapping` +- **Files Modified:** `constants.py`, `order.py` + +--- + +**End of Documentation** diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4a4c7c86d..271cacc02 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -1,8 +1,15 @@ +""" +connection.py - WITH COMPREHENSIVE STORE 2 LOGGING +Every step is logged so we can trace exactly where it breaks. +Logging only triggers for Store 2 to avoid cluttering logs. +""" + import base64 import functools import hashlib import hmac import json +import traceback import frappe from frappe import _ @@ -10,120 +17,646 @@ from shopify.session import Session from ecommerce_integrations.shopify.constants import ( - API_VERSION, - EVENT_MAPPER, - SETTING_DOCTYPE, - WEBHOOK_EVENTS, + API_VERSION, + EVENT_MAPPER, + SETTING_DOCTYPE, + WEBHOOK_EVENTS, ) from ecommerce_integrations.shopify.utils import create_shopify_log -def temp_shopify_session(func): - """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns.""" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) +def log_store2(step, message, store_name=None): + """Helper function to log only for Store 2.""" + if store_name and store_name != "Store 1": + frappe.log_error( + title=f"[STORE2 DEBUG] Step {step}", + message=f"Store: {store_name}\n\n{message}" + ) - setting = frappe.get_doc(SETTING_DOCTYPE) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) - with Session.temp(*auth_details): - return func(*args, **kwargs) - - return wrapper +def temp_shopify_session(func): + """Any function that needs to access shopify api needs this decorator. + + Store-aware: Checks for store_name in kwargs or frappe.local.shopify_store_name + to determine which store's credentials to use. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: + return func(*args, **kwargs) + + setting = frappe.get_doc(SETTING_DOCTYPE) + if setting.is_enabled(): + # Determine which store's credentials to use + store_name = kwargs.get('store_name') or getattr(frappe.local, 'shopify_store_name', None) + + # STORE 2 LOGGING + log_store2("DECORATOR-1", f""" +temp_shopify_session called for function: {func.__name__} +store_name from kwargs: {kwargs.get('store_name')} +store_name from frappe.local: {getattr(frappe.local, 'shopify_store_name', None)} +Final store_name: {store_name} +enable_store_2: {setting.enable_store_2} +""", store_name) + + # Use Store 2 credentials if specified and enabled + if store_name and store_name != "Store 1" and setting.enable_store_2: + password_2 = setting.get_password("password_2") + + log_store2("DECORATOR-2", f""" +Using Store 2 credentials +shopify_url_2: {setting.shopify_url_2} +password_2 exists: {bool(password_2)} +password_2 length: {len(password_2) if password_2 else 0} +""", store_name) + + if not password_2: + log_store2("DECORATOR-ERROR", "password_2 is None/empty!", store_name) + frappe.throw(_("Store 2 API password not configured")) + + auth_details = (setting.shopify_url_2, API_VERSION, password_2) + else: + # Default to Store 1 + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + + try: + log_store2("DECORATOR-3", f"About to create Shopify session with URL: {auth_details[0]}", store_name) + with Session.temp(*auth_details): + log_store2("DECORATOR-4", f"Session created, calling {func.__name__}", store_name) + result = func(*args, **kwargs) + log_store2("DECORATOR-5", f"Function {func.__name__} completed successfully", store_name) + return result + except Exception as e: + log_store2("DECORATOR-ERROR", f""" +Exception in temp_shopify_session! +Function: {func.__name__} +Error: {str(e)} +Type: {type(e).__name__} +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + return wrapper def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: - """Register required webhooks with shopify and return registered webhooks.""" - new_webhooks = [] + """Register required webhooks with shopify and return registered webhooks.""" + new_webhooks = [] - # clear all stale webhooks matching current site url before registering new ones - unregister_webhooks(shopify_url, password) + # clear all stale webhooks matching current site url before registering new ones + unregister_webhooks(shopify_url, password) - with Session.temp(shopify_url, API_VERSION, password): - for topic in WEBHOOK_EVENTS: - webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"}) + with Session.temp(shopify_url, API_VERSION, password): + for topic in WEBHOOK_EVENTS: + webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"}) - if webhook.is_valid(): - new_webhooks.append(webhook) - else: - create_shopify_log( - status="Error", - response_data=webhook.to_dict(), - exception=webhook.errors.full_messages(), - ) + if webhook.is_valid(): + new_webhooks.append(webhook) + else: + create_shopify_log( + status="Error", + response_data=webhook.to_dict(), + exception=webhook.errors.full_messages(), + ) - return new_webhooks + return new_webhooks def unregister_webhooks(shopify_url: str, password: str) -> None: - """Unregister all webhooks from shopify that correspond to current site url.""" - url = get_current_domain_name() + """Unregister all webhooks from shopify that correspond to current site url.""" + url = get_current_domain_name() - with Session.temp(shopify_url, API_VERSION, password): - for webhook in Webhook.find(): - if url in webhook.address: - webhook.destroy() + with Session.temp(shopify_url, API_VERSION, password): + for webhook in Webhook.find(): + if url in webhook.address: + webhook.destroy() def get_current_domain_name() -> str: - """Get current site domain name. E.g. test.erpnext.com - - If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url. - """ - if frappe.conf.developer_mode and frappe.conf.localtunnel_url: - return frappe.conf.localtunnel_url - else: - return frappe.request.host + """Get current site domain name.""" + if frappe.conf.developer_mode and frappe.conf.localtunnel_url: + return frappe.conf.localtunnel_url + else: + return frappe.request.host def get_callback_url() -> str: - """Shopify calls this url when new events occur to subscribed webhooks. - - If developer_mode is enabled and localtunnel_url is set in site config then callback url is set to localtunnel_url. - """ - url = get_current_domain_name() - - return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data" + """Shopify calls this url when new events occur to subscribed webhooks.""" + url = get_current_domain_name() + return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data" @frappe.whitelist(allow_guest=True) def store_request_data() -> None: - if frappe.request: - hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - - _validate_request(frappe.request, hmac_header) - - data = json.loads(frappe.request.data) - event = frappe.request.headers.get("X-Shopify-Topic") - - process_request(data, event) - - -def process_request(data, event): - # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - - # enqueue backround job - frappe.enqueue( - method=EVENT_MAPPER[event], - queue="short", - timeout=300, - is_async=True, - **{"payload": data, "request_id": log.name}, - ) - - -def _validate_request(req, hmac_header): - settings = frappe.get_doc(SETTING_DOCTYPE) - secret_key = settings.shared_secret - - sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) - - if sig != bytes(hmac_header.encode()): - create_shopify_log(status="Error", request_data=req.data) - frappe.throw(_("Unverified Webhook Data")) + """ + Central webhook endpoint for all Shopify stores. + Routes webhooks to the correct handler based on shop domain. + """ + + # ========================================================================= + # STEP 1: Initial request validation + # ========================================================================= + if not frappe.request: + frappe.log_error( + title="[STORE2 DEBUG] Step 1 - FAILED", + message="No frappe.request object!" + ) + frappe.throw(_("Invalid request")) + return + + # Get headers early for logging + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + event = frappe.get_request_header("X-Shopify-Topic") + hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + + # Check if this MIGHT be Store 2 (for early logging) + setting = frappe.get_doc(SETTING_DOCTYPE) + is_likely_store2 = ( + setting.enable_store_2 and + setting.shopify_url_2 and + shop_domain and + shop_domain in setting.shopify_url_2 + ) + + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 1 - Request Received", + message=f""" +====================================== +STORE 2 WEBHOOK RECEIVED +====================================== +shop_domain: {shop_domain} +event: {event} +hmac_header exists: {bool(hmac_header)} +hmac_header length: {len(hmac_header) if hmac_header else 0} +hmac_header (first 20): {hmac_header[:20] if hmac_header else 'NONE'}... +request.data length: {len(frappe.request.data) if frappe.request.data else 0} +request.data (first 200): {frappe.request.data[:200] if frappe.request.data else 'NONE'} +====================================== +""" + ) + + # ========================================================================= + # STEP 2: Validate shop_domain header + # ========================================================================= + if not shop_domain: + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 2 - FAILED", + message="shop_domain header is missing!" + ) + frappe.throw(_("Missing shop domain header")) + return + + if is_likely_store2: + frappe.log_error( + title="[STORE2 DEBUG] Step 2 - shop_domain OK", + message=f"shop_domain: {shop_domain}" + ) + + # ========================================================================= + # STEP 3: Determine which store + # ========================================================================= + store_name = None + shared_secret = None + + # Check Store 1 + if setting.shopify_url and shop_domain in setting.shopify_url: + store_name = "Store 1" + shared_secret = setting.shared_secret + + # Check Store 2 + elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2: + store_name = setting.store_2_name or "Store 2" + shared_secret = setting.shared_secret_2 + + frappe.log_error( + title="[STORE2 DEBUG] Step 3 - Store Matched", + message=f""" +Store identified as: {store_name} +shop_domain: {shop_domain} +shopify_url_2: {setting.shopify_url_2} +shared_secret_2 exists: {bool(shared_secret)} +shared_secret_2 type: {type(shared_secret)} +shared_secret_2 length: {len(shared_secret) if shared_secret else 0} +shared_secret_2 (first 10): {shared_secret[:10] if shared_secret else 'NONE'}... +""" + ) + + # Unknown store + else: + frappe.log_error( + title="[STORE2 DEBUG] Step 3 - NO MATCH", + message=f""" +shop_domain: {shop_domain} +Store 1 URL: {setting.shopify_url} +Store 2 URL: {setting.shopify_url_2} +Store 2 Enabled: {setting.enable_store_2} +""" + ) + frappe.throw(_(f"Unknown Shopify store: {shop_domain}")) + return + + # ========================================================================= + # STEP 4: Validate shared_secret exists + # ========================================================================= + if store_name != "Store 1": + frappe.log_error( + title="[STORE2 DEBUG] Step 4 - Validating shared_secret", + message=f""" +shared_secret is None: {shared_secret is None} +shared_secret is empty string: {shared_secret == ''} +shared_secret is falsy: {not shared_secret} +bool(shared_secret): {bool(shared_secret)} +repr(shared_secret): {repr(shared_secret)[:50] if shared_secret else 'None'} +""" + ) + + if not shared_secret: + log_store2("4-FAILED", "shared_secret is None or empty!", store_name) + frappe.throw(_(f"Shared secret not configured for {store_name}")) + return + + log_store2("4-OK", "shared_secret exists and is not empty", store_name) + + # ========================================================================= + # STEP 5: Validate HMAC header exists + # ========================================================================= + log_store2("5", f""" +Checking HMAC header... +hmac_header exists: {bool(hmac_header)} +hmac_header is None: {hmac_header is None} +hmac_header type: {type(hmac_header)} +""", store_name) + + if not hmac_header: + log_store2("5-FAILED", "hmac_header is None or empty!", store_name) + frappe.throw(_("Missing HMAC signature header")) + return + + log_store2("5-OK", f"HMAC header present: {hmac_header[:20]}...", store_name) + + # ========================================================================= + # STEP 6: HMAC Validation + # ========================================================================= + log_store2("6", f""" +About to validate HMAC... +shared_secret type: {type(shared_secret)} +shared_secret length: {len(shared_secret)} +hmac_header type: {type(hmac_header)} +hmac_header length: {len(hmac_header)} +request.data type: {type(frappe.request.data)} +request.data length: {len(frappe.request.data)} +""", store_name) + + try: + log_store2("6a", "Calling shared_secret.encode('utf8')...", store_name) + secret_bytes = shared_secret.encode("utf8") + log_store2("6b", f"Success! secret_bytes length: {len(secret_bytes)}", store_name) + + log_store2("6c", "Computing HMAC...", store_name) + computed_hmac = hmac.new(secret_bytes, frappe.request.data, hashlib.sha256) + log_store2("6d", "HMAC computed, getting digest...", store_name) + + digest = computed_hmac.digest() + log_store2("6e", f"Digest obtained, length: {len(digest)}", store_name) + + sig = base64.b64encode(digest) + log_store2("6f", f"Base64 encoded sig: {sig[:30]}...", store_name) + + log_store2("6g", "Encoding hmac_header to bytes...", store_name) + expected_sig = bytes(hmac_header.encode()) + log_store2("6h", f"Expected sig: {expected_sig[:30]}...", store_name) + + log_store2("6i", f""" +Comparing signatures... +Computed: {sig} +Expected: {expected_sig} +Match: {sig == expected_sig} +""", store_name) + + if sig != expected_sig: + log_store2("6-FAILED", f""" +HMAC MISMATCH! +Computed: {sig} +Expected: {expected_sig} + +This means the shared_secret in ERPNext doesn't match Shopify's signing key. +Check: Shopify Admin → Settings → Notifications → Webhooks → "Your webhooks will be signed with: XXX" +""", store_name) + create_shopify_log(status="Error", request_data=frappe.request.data) + frappe.throw(_("Unverified Webhook Data")) + return + + log_store2("6-OK", "HMAC validation PASSED!", store_name) + + except AttributeError as e: + log_store2("6-EXCEPTION", f""" +AttributeError during HMAC validation! +Error: {str(e)} +This usually means shared_secret or hmac_header is None. + +shared_secret is None: {shared_secret is None} +hmac_header is None: {hmac_header is None} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + except Exception as e: + log_store2("6-EXCEPTION", f""" +Exception during HMAC validation! +Error type: {type(e).__name__} +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + # ========================================================================= + # STEP 7: Parse JSON data + # ========================================================================= + log_store2("7", "Parsing JSON data...", store_name) + + try: + data = json.loads(frappe.request.data) + log_store2("7-OK", f""" +JSON parsed successfully! +Order ID: {data.get('id', 'N/A')} +Order Number: {data.get('name', 'N/A')} +Customer: {data.get('customer', {}).get('email', 'N/A') if data.get('customer') else 'N/A'} +Total Price: {data.get('total_price', 'N/A')} +Line Items Count: {len(data.get('line_items', []))} +""", store_name) + except json.JSONDecodeError as e: + log_store2("7-FAILED", f""" +JSON parse error! +Error: {str(e)} +Raw data (first 500): {frappe.request.data[:500]} +""", store_name) + frappe.throw(_("Invalid JSON in webhook payload")) + return + + # ========================================================================= + # STEP 8: Validate event type + # ========================================================================= + log_store2("8", f""" +Validating event type... +Event: {event} +Event in EVENT_MAPPER: {event in EVENT_MAPPER} +Available events: {list(EVENT_MAPPER.keys())} +""", store_name) + + if not event: + log_store2("8-FAILED", "Event header is missing!", store_name) + frappe.throw(_("Missing webhook event type")) + return + + if event not in EVENT_MAPPER: + log_store2("8-FAILED", f"Event '{event}' not in EVENT_MAPPER!", store_name) + frappe.throw(_(f"Unsupported webhook event: {event}")) + return + + log_store2("8-OK", f"Event '{event}' is valid, maps to: {EVENT_MAPPER[event]}", store_name) + + # ========================================================================= + # STEP 9: Set store context + # ========================================================================= + log_store2("9", f"Setting frappe.local.shopify_store_name = '{store_name}'", store_name) + frappe.local.shopify_store_name = store_name + log_store2("9-OK", f"Store context set. Verified: {frappe.local.shopify_store_name}", store_name) + + # ========================================================================= + # STEP 10: Call process_request + # ========================================================================= + log_store2("10", f""" +About to call process_request() +Event: {event} +Store: {store_name} +Order ID: {data.get('id')} +""", store_name) + + try: + process_request(data, event, store_name) + log_store2("10-OK", "process_request() completed without exception", store_name) + except Exception as e: + log_store2("10-EXCEPTION", f""" +Exception in process_request! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + +def process_request(data, event, store_name=None): + """Process webhook request with store context.""" + + # ========================================================================= + # STEP 11: Inside process_request + # ========================================================================= + log_store2("11", f""" +Inside process_request() +Event: {event} +Store: {store_name} +Order ID: {data.get('id')} +Method to call: {EVENT_MAPPER[event]} +""", store_name) + + # ========================================================================= + # STEP 11.5: Early filter for noisy `orders/updated` webhooks + # ========================================================================= + # NOTE: Per business requirements, this fingerprint ONLY tracks: + # - Shipping address + # - Billing address + # - Order notes + # + # Line items are handled via the dedicated `orders/edited` webhook, so they + # are *not* part of this fingerprint. This means: + # - Old/timeline/metadata-only updates on untouched orders are dropped here. + # - Only REAL address / note changes for existing Sales Orders will proceed. + if event == "orders/updated": + try: + from ecommerce_integrations.shopify.constants import ORDER_ID_FIELD + + order_id = str(data.get("id") or "") + so_name = None + if order_id: + so_name = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: order_id}, "name") + + # If we have a matching Sales Order, compare fingerprints + if so_name: + new_fingerprint = _build_order_fingerprint(data) + + try: + old_fingerprint = frappe.db.get_value( + "Sales Order", so_name, "custom_shopify_fingerprint" + ) or "" + except Exception: + # If the field doesn't exist yet or any error occurs, skip fingerprint filter + log_store2( + "11.5-SKIP", + f"Fingerprint field missing or error while reading for SO {so_name}. " + f"Proceeding without early-exit filter.", + store_name, + ) + old_fingerprint = "" + + # If fingerprint unchanged, drop this webhook before logging / enqueue + if old_fingerprint and new_fingerprint == old_fingerprint: + log_store2( + "11.5-SKIP", + f"orders/updated fingerprint UNCHANGED for order_id={order_id}, so_name={so_name}. " + f"Skipping log + enqueue to avoid noise.", + store_name, + ) + return + + # Fingerprint changed or first time: update it so future metadata-only + # webhooks on the same order can be skipped. + try: + frappe.db.set_value( + "Sales Order", + so_name, + "custom_shopify_fingerprint", + new_fingerprint, + update_modified=False, + ) + frappe.db.commit() + log_store2( + "11.5-SET", + f"Updated fingerprint for SO {so_name}. " + f"Old: {old_fingerprint or 'EMPTY'} | New: {new_fingerprint}", + store_name, + ) + except Exception as e: + log_store2( + "11.5-SET-ERROR", + f"Failed to update fingerprint for SO {so_name}. Error: {str(e)}", + store_name, + ) + except Exception as e: + # Fail open: if anything goes wrong in fingerprint logic, we still + # want the webhook to be processed normally. + log_store2( + "11.5-EXCEPTION", + f"Exception in orders/updated fingerprint filter: {str(e)}\n" + f"Traceback:\n{traceback.format_exc()}", + store_name, + ) + + # ========================================================================= + # STEP 12: Create Shopify log + # ========================================================================= + log_store2("12", "Creating Shopify log entry...", store_name) + + try: + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) + log_store2("12-OK", f"Log created: {log.name}", store_name) + except Exception as e: + log_store2("12-EXCEPTION", f""" +Failed to create Shopify log! +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + # ========================================================================= + # STEP 13: Enqueue background job + # ========================================================================= + log_store2("13", f""" +About to enqueue background job... +Method: {EVENT_MAPPER[event]} +Queue: short +Timeout: 300 +Kwargs: payload (order data), request_id={log.name}, store_name={store_name} +""", store_name) + + try: + frappe.enqueue( + method=EVENT_MAPPER[event], + queue="short", + timeout=300, + is_async=True, + **{"payload": data, "request_id": log.name, "store_name": store_name}, + ) + log_store2("13-OK", f""" +Job enqueued successfully! +Method: {EVENT_MAPPER[event]} +Log ID: {log.name} +Store: {store_name} + +The webhook handler has completed. +The background worker should now pick up the job. +Check RQ Job doctype for the job status. +""", store_name) + except Exception as e: + log_store2("13-EXCEPTION", f""" +Failed to enqueue job! +Error: {str(e)} + +Traceback: +{traceback.format_exc()} +""", store_name) + raise + + +def _build_order_fingerprint(data): + """Build a fingerprint of ONLY the fields we care about for `orders/updated`. + + Per the current requirement, this fingerprint includes: + - Order note + - Billing address (core fields) + - Shipping address (core fields) + + Line items and other metadata are intentionally NOT included here. Line-item + changes are handled via the dedicated `orders/edited` webhook instead. + """ + fingerprint_data = { + "note": data.get("note") or "", + "billing_address": _address_hash(data.get("billing_address")), + "shipping_address": _address_hash(data.get("shipping_address")), + } + + raw = json.dumps(fingerprint_data, sort_keys=True) + return hashlib.md5(raw.encode()).hexdigest() + + +def _address_hash(address): + """Return a stable string representing the address fields we care about.""" + if not address: + return "" + + return "|".join( + [ + str(address.get("address1") or "").strip(), + str(address.get("address2") or "").strip(), + str(address.get("city") or "").strip(), + str(address.get("province") or "").strip(), + str(address.get("zip") or "").strip(), + str(address.get("country") or "").strip(), + str(address.get("phone") or "").strip(), + ] + ) + + +def _validate_request(req, hmac_header, shared_secret): + """Validate Shopify webhook using HMAC. + + Note: This function is now bypassed - validation is done inline in store_request_data + with detailed logging. Keeping for backward compatibility. + """ + sig = base64.b64encode(hmac.new(shared_secret.encode("utf8"), req.data, hashlib.sha256).digest()) + + if sig != bytes(hmac_header.encode()): + create_shopify_log(status="Error", request_data=req.data) + frappe.throw(_("Unverified Webhook Data")) \ No newline at end of file diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index 47720e032..a3f25a72e 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -14,6 +14,8 @@ "orders/fulfilled", "orders/cancelled", "orders/partially_fulfilled", + "orders/edited", # Clean signal for line item edits + "orders/updated", # Kept only for address / notes changes (filtered via fingerprint) ] EVENT_MAPPER = { @@ -22,6 +24,8 @@ "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", + "orders/edited": "ecommerce_integrations.shopify.order.handle_order_edited", + "orders/updated": "ecommerce_integrations.shopify.order.handle_order_updated", } SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"] diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 3a0ee952f..ffa84e65c 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -28,8 +28,19 @@ def sync_customer(self, customer: dict[str, Any]) -> None: customer_group = self.setting.customer_group super().sync_customer(customer_name, customer_group) - billing_address = customer.get("billing_address", {}) or customer.get("default_address") + # Handle billing address: only use shipping if billing is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = customer.get("billing_address") shipping_address = customer.get("shipping_address", {}) + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = customer.get("default_address", {}) if billing_address: self.create_customer_address( @@ -54,8 +65,19 @@ def create_customer_address( super().create_customer_address(address_fields) def update_existing_addresses(self, customer): - billing_address = customer.get("billing_address", {}) or customer.get("default_address") + # Handle billing address: only use shipping if billing is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = customer.get("billing_address") shipping_address = customer.get("shipping_address", {}) + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = customer.get("default_address", {}) customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name")) email = customer.get("email") diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index 01722169b..a74234f40 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -14,6 +14,15 @@ "shared_secret", "section_break_4", "webhooks", + "store_2_section", + "enable_store_2", + "store_2_name", + "column_break_store_2", + "shopify_url_2", + "password_2", + "shared_secret_2", + "webhooks_section_2", + "webhooks_2", "customer_settings_section", "default_customer", "column_break_14", @@ -113,6 +122,67 @@ "options": "Shopify Webhooks", "read_only": 1 }, + { + "collapsible": 1, + "fieldname": "store_2_section", + "fieldtype": "Section Break", + "label": "Store 2 - Additional Shopify Store" + }, + { + "default": "0", + "fieldname": "enable_store_2", + "fieldtype": "Check", + "label": "Enable Store 2" + }, + { + "default": "Store 2", + "depends_on": "enable_store_2", + "description": "User-friendly name for reporting (e.g., 'ZipCovers')", + "fieldname": "store_2_name", + "fieldtype": "Data", + "label": "Store 2 Name" + }, + { + "fieldname": "column_break_store_2", + "fieldtype": "Column Break" + }, + { + "depends_on": "enable_store_2", + "description": "e.g., zipcovers.myshopify.com", + "fieldname": "shopify_url_2", + "fieldtype": "Data", + "label": "Shop URL (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "depends_on": "enable_store_2", + "fieldname": "password_2", + "fieldtype": "Password", + "label": "Password / Access Token (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "depends_on": "enable_store_2", + "fieldname": "shared_secret_2", + "fieldtype": "Data", + "label": "Shared Secret / API Secret (Store 2)", + "mandatory_depends_on": "eval:doc.enable_store_2" + }, + { + "collapsible": 1, + "depends_on": "enable_store_2", + "fieldname": "webhooks_section_2", + "fieldtype": "Section Break", + "label": "Webhooks (Store 2)" + }, + { + "depends_on": "enable_store_2", + "fieldname": "webhooks_2", + "fieldtype": "Table", + "label": "Webhooks", + "options": "Shopify Webhook Store 2", + "read_only": 1 + }, { "fieldname": "customer_settings_section", "fieldtype": "Section Break", diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py index dc974e70e..8a98ef027 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py @@ -40,6 +40,11 @@ def validate(self): if self.shopify_url: self.shopify_url = self.shopify_url.replace("https://", "") + + # Clean Store 2 URL if provided + if self.shopify_url_2: + self.shopify_url_2 = self.shopify_url_2.replace("https://", "") + self._handle_webhooks() self._validate_warehouse_links() self._initalize_default_values() @@ -52,22 +57,68 @@ def on_update(self): migrate_from_old_connector() def _handle_webhooks(self): + """Handle webhook registration/unregistration for both stores.""" + + # Handle Store 1 webhooks if self.is_enabled() and not self.webhooks: new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password")) if not new_webhooks: - msg = _("Failed to register webhooks with Shopify.") + "
" + msg = _("Failed to register webhooks with Shopify Store 1.") + "
" msg += _("Please check credentials and retry.") + " " msg += _("Disabling and re-enabling the integration might also help.") frappe.throw(msg) for webhook in new_webhooks: self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic}) + + frappe.logger().info(f"Registered {len(new_webhooks)} webhooks for Store 1") elif not self.is_enabled(): connection.unregister_webhooks(self.shopify_url, self.get_password("password")) - self.webhooks = list() # remove all webhooks + frappe.logger().info("Unregistered Store 1 webhooks") + + # Handle Store 2 webhooks + if self.enable_store_2 and self.shopify_url_2 and not self.webhooks_2: + try: + new_webhooks_2 = connection.register_webhooks( + self.shopify_url_2, + self.get_password("password_2") + ) + + if not new_webhooks_2: + msg = _("Failed to register webhooks with Shopify Store 2.") + "
" + msg += _("Please check Store 2 credentials and retry.") + frappe.throw(msg) + + for webhook in new_webhooks_2: + self.append("webhooks_2", {"webhook_id": webhook.id, "method": webhook.topic}) + + frappe.logger().info(f"Registered {len(new_webhooks_2)} webhooks for Store 2 ({self.store_2_name})") + + except Exception as e: + frappe.log_error( + title="Store 2 Webhook Registration Failed", + message=f"Error registering webhooks for Store 2: {str(e)}" + ) + # Don't throw - allow Store 1 to continue working + frappe.msgprint( + _("Warning: Failed to register Store 2 webhooks. Store 1 will continue working. Error: {0}").format(str(e)), + alert=True + ) + + elif not self.enable_store_2 and self.shopify_url_2 and self.webhooks_2: + # Store 2 disabled but webhooks exist - unregister them + try: + connection.unregister_webhooks(self.shopify_url_2, self.get_password("password_2")) + self.webhooks_2 = list() # remove all Store 2 webhooks + frappe.logger().info("Unregistered Store 2 webhooks") + except Exception as e: + frappe.log_error( + title="Store 2 Webhook Unregistration Failed", + message=f"Error unregistering webhooks for Store 2: {str(e)}" + ) def _validate_warehouse_links(self): for wh_map in self.shopify_warehouse_mapping: @@ -171,6 +222,18 @@ def setup_custom_fields(): read_only=1, print_hide=1, ), + # Fingerprint used to filter noisy `orders/updated` webhooks. + # Hidden technical field – do not show in UI. + dict( + fieldname="custom_shopify_fingerprint", + label="Shopify Fingerprint", + fieldtype="Small Text", + insert_after=ORDER_STATUS_FIELD, + read_only=1, + print_hide=1, + hidden=1, + no_copy=1, + ), ], "Sales Order Item": [ dict( diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json new file mode 100644 index 000000000..f9eec3bce --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "creation": "2025-01-02 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "webhook_id", + "method" + ], + "fields": [ + { + "fieldname": "webhook_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Webhook ID", + "read_only": 1 + }, + { + "fieldname": "method", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Method", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-01-02 00:00:00.000000", + "modified_by": "Administrator", + "module": "Shopify", + "name": "Shopify Webhook Store 2", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} + diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py new file mode 100644 index 000000000..2a093fe61 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe and contributors +# For license information, please see LICENSE + +from frappe.model.document import Document + + +class ShopifyWebhookStore2(Document): + pass + diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 5ffc0ebae..06dbed8be 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -14,12 +14,24 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def prepare_delivery_note(payload, request_id=None): +def prepare_delivery_note(payload, request_id=None, store_name=None): + """Prepare delivery note from Shopify fulfillment. + + Args: + payload: Order data from Shopify + request_id: Shopify Log entry ID + store_name: Name of the store (for logging) + """ frappe.set_user("Administrator") setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id order = payload + + # Set store context for API calls in this background job + if store_name: + frappe.logger().info(f"Preparing delivery note for order from {store_name}") + frappe.local.shopify_store_name = store_name try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index f841cb416..2a983e488 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -10,7 +10,14 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def prepare_sales_invoice(payload, request_id=None): +def prepare_sales_invoice(payload, request_id=None, store_name=None): + """Prepare sales invoice from Shopify order. + + Args: + payload: Order data from Shopify + request_id: Shopify Log entry ID + store_name: Name of the store (for logging) + """ from ecommerce_integrations.shopify.order import get_sales_order order = payload @@ -18,6 +25,11 @@ def prepare_sales_invoice(payload, request_id=None): frappe.set_user("Administrator") setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id + + # Set store context for API calls in this background job + if store_name: + frappe.logger().info(f"Preparing sales invoice for order from {store_name}") + frappe.local.shopify_store_name = store_name try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 0570d035b..2a64ec8a2 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -1,4 +1,10 @@ +""" +order.py - WITH COMPREHENSIVE STORE 2 LOGGING +This handles the background job for orders/create webhook. +""" + import json +import traceback from typing import Literal, Optional import frappe @@ -9,13 +15,13 @@ from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import ( - CUSTOMER_ID_FIELD, - EVENT_MAPPER, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SETTING_DOCTYPE, + CUSTOMER_ID_FIELD, + EVENT_MAPPER, + ORDER_ID_FIELD, + ORDER_ITEM_DISCOUNT_FIELD, + ORDER_NUMBER_FIELD, + ORDER_STATUS_FIELD, + SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.customer import ShopifyCustomer from ecommerce_integrations.shopify.product import create_items_if_not_exist, get_item_code @@ -24,412 +30,1439 @@ from ecommerce_integrations.utils.taxation import get_dummy_tax_category DEFAULT_TAX_FIELDS = { - "sales_tax": "default_sales_tax_account", - "shipping": "default_shipping_charges_account", + "sales_tax": "default_sales_tax_account", + "shipping": "default_shipping_charges_account", } -def sync_sales_order(payload, request_id=None): - order = payload - frappe.set_user("Administrator") - frappe.flags.request_id = request_id - - if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): - create_shopify_log(status="Invalid", message="Sales order already exists, not synced") - return - try: - shopify_customer = order.get("customer") if order.get("customer") is not None else {} - shopify_customer["billing_address"] = order.get("billing_address", "") - shopify_customer["shipping_address"] = order.get("shipping_address", "") - customer_id = shopify_customer.get("id") - if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) - if not customer.is_synced(): - customer.sync_customer(customer=shopify_customer) - else: - customer.update_existing_addresses(shopify_customer) - - create_items_if_not_exist(order) - - setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) - except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) - else: - create_shopify_log(status="Success") - - -def create_order(order, setting, company=None): - # local import to avoid circular dependencies - from ecommerce_integrations.shopify.fulfillment import create_delivery_note - from ecommerce_integrations.shopify.invoice import create_sales_invoice - - so = create_sales_order(order, setting, company) - if so: - if order.get("financial_status") == "paid": - create_sales_invoice(order, setting, so) - - if order.get("fulfillments"): - create_delivery_note(order, setting, so) - - -def create_sales_order(shopify_order, setting, company=None): - customer = setting.default_customer - if shopify_order.get("customer", {}): - if customer_id := shopify_order.get("customer", {}).get("id"): - customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") - - so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") - - if not so: - items = get_order_items( - shopify_order.get("line_items"), - setting, - getdate(shopify_order.get("created_at")), - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if not items: - message = ( - "Following items exists in the shopify order but relevant records were" - " not found in the shopify Product master" - ) - product_not_exists = [] # TODO: fix missing items - message += "\n" + ", ".join(product_not_exists) - - create_shopify_log(status="Error", exception=message, rollback=True) - - return "" - - taxes = get_order_taxes(shopify_order, setting, items) - so = frappe.get_doc( - { - "doctype": "Sales Order", - "naming_series": setting.sales_order_series or "SO-Shopify-", - ORDER_ID_FIELD: str(shopify_order.get("id")), - ORDER_NUMBER_FIELD: shopify_order.get("name"), - "customer": customer, - "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), - "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": setting.company, - "selling_price_list": get_dummy_price_list(), - "ignore_pricing_rule": 1, - "items": items, - "taxes": taxes, - "tax_category": get_dummy_tax_category(), - } - ) - - if company: - so.update({"company": company, "status": "Draft"}) - so.flags.ignore_mandatory = True - so.flags.shopiy_order_json = json.dumps(shopify_order) - so.save(ignore_permissions=True) - so.submit() - - if shopify_order.get("note"): - so.add_comment(text=f"Order Note: {shopify_order.get('note')}") - - else: - so = frappe.get_doc("Sales Order", so) - - return so - - -def get_order_items(order_items, setting, delivery_date, taxes_inclusive): - items = [] - all_product_exists = True - product_not_exists = [] - - for shopify_item in order_items: - if not shopify_item.get("product_exists"): - all_product_exists = False - product_not_exists.append( - {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} - ) - continue - - if all_product_exists: - item_code = get_item_code(shopify_item) - items.append( - { - "item_code": item_code, - "item_name": shopify_item.get("name"), - "rate": _get_item_price(shopify_item, taxes_inclusive), - "delivery_date": delivery_date, - "qty": shopify_item.get("quantity"), - "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": setting.warehouse, - ORDER_ITEM_DISCOUNT_FIELD: ( - _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) - ), - } - ) - else: - items = [] - - return items - - +def log_store2(step, message, store_name=None): + """Helper function to log only for Store 2.""" + if store_name and store_name != "Store 1": + frappe.log_error( + title=f"[STORE2 ORDER] Step {step}", + message=f"Store: {store_name}\n\n{message}" + ) + + +def handle_order_edited(payload, request_id=None, store_name=None): + """Handle Shopify `orders/edited` webhook. + + This topic is a CLEAN signal from Shopify that only fires when an order + is edited (line items added/removed/changed via the order edit flow). + + We simply route it into the existing `update_sales_order` logic, which + already has robust line-item comparison and change tracking. + """ + log_store2( + "BG-ORDER-EDITED", + f"orders/edited webhook received. request_id={request_id}, store_name={store_name}, " + f"order_id={payload.get('id')}, order_number={payload.get('name')}", + store_name, + ) + return update_sales_order(payload, request_id=request_id, store_name=store_name) + + +def handle_order_updated(payload, request_id=None, store_name=None): + """Handle Shopify `orders/updated` webhook. + + This handler ONLY processes: + - Order notes + - Customer address changes (shipping, billing) + + All other changes (line items, prices, etc.) are ignored here. + Line item changes are handled via the dedicated `orders/edited` webhook. + """ + order = payload + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + + try: + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + shopify_customer = order.get("customer", {}) if order.get("customer") else {} + customer_id = shopify_customer.get("id") + + # Check if Sales Order exists + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + + if not sales_order_name: + # Order doesn't exist - this shouldn't happen for orders/updated, but handle gracefully + create_shopify_log( + status="Info", + message=f"Order {order_number} not found in ERPNext for orders/updated webhook", + request_data=order, + ) + return + + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + # ONLY check notes + old_note = sales_order.get("note", "") or "" + new_note = order.get("note", "") or "" + if old_note.strip() != new_note.strip(): + changes["note"] = { + "old": old_note, + "new": new_note + } + + # ONLY check addresses (shipping and billing) + shipping_address = order.get("shipping_address", {}) + if shipping_address and sales_order.customer: + shipping_addr_doc = _get_customer_address_by_type(sales_order.customer, "Shipping") + + if shipping_addr_doc: + old_addr = { + "address_line1": shipping_addr_doc.get("address_line1", ""), + "address_line2": shipping_addr_doc.get("address_line2", ""), + "city": shipping_addr_doc.get("city", ""), + "state": shipping_addr_doc.get("state", ""), + "pincode": shipping_addr_doc.get("pincode", ""), + "country": shipping_addr_doc.get("country", ""), + "phone": shipping_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": shipping_address.get("address1", ""), + "address_line2": shipping_address.get("address2", ""), + "city": shipping_address.get("city", ""), + "state": shipping_address.get("province", ""), + "pincode": shipping_address.get("zip", ""), + "country": shipping_address.get("country", ""), + "phone": shipping_address.get("phone", ""), + } + + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["shipping_address"] = { + "old": old_addr, + "new": new_addr + } + + # Compare billing address + billing_address = order.get("billing_address") + billing_is_same_as_shipping = False + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + if shipping_address: + billing_address = shipping_address + billing_is_same_as_shipping = True + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + if billing_address and sales_order.customer: + billing_addr_doc = _get_customer_address_by_type(sales_order.customer, "Billing") + + if billing_addr_doc: + old_addr = { + "address_line1": billing_addr_doc.get("address_line1", ""), + "address_line2": billing_addr_doc.get("address_line2", ""), + "city": billing_addr_doc.get("city", ""), + "state": billing_addr_doc.get("state", ""), + "pincode": billing_addr_doc.get("pincode", ""), + "country": billing_addr_doc.get("country", ""), + "phone": billing_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": billing_address.get("address1", ""), + "address_line2": billing_address.get("address2", ""), + "city": billing_address.get("city", ""), + "state": billing_address.get("province", ""), + "pincode": billing_address.get("zip", ""), + "country": billing_address.get("country", ""), + "phone": billing_address.get("phone", ""), + } + + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["billing_address"] = { + "old": old_addr, + "new": new_addr, + "is_same_as_shipping": billing_is_same_as_shipping + } + + # Skip if no changes detected + if not changes: + return + + # Update addresses if changed + if "shipping_address" in changes or "billing_address" in changes: + shopify_customer["billing_address"] = billing_address + shopify_customer["shipping_address"] = shipping_address + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if customer.is_synced(): + customer.update_existing_addresses(shopify_customer) + + # Update note if changed + # ERPNext's standard Sales Order does not have a direct "note" field; + # the original integration stored Shopify notes as comments. + if "note" in changes and new_note: + sales_order_doc = frappe.get_doc("Sales Order", sales_order_name) + sales_order_doc.add_comment(text=f"Order Note: {new_note}") + + # Log the changes + create_shopify_log( + status="Success", + message=f"Order {order_number} updated: {', '.join(changes.keys())}", + request_data=order, + response_data={"change_details": changes} + ) + + frappe.db.commit() + + except Exception as e: + frappe.log_error( + title=f"Failed to update order {order_number}", + message=f"Error: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + create_shopify_log( + status="Error", + message=f"Failed to update order {order_number}: {str(e)}", + request_data=order, + exception=e, + ) + raise + + +def sync_sales_order(payload, request_id=None, store_name=None): + """Sync Shopify order to ERPNext Sales Order. + + This is called as a BACKGROUND JOB by the RQ worker. + """ + order = payload + + # ========================================================================= + # STEP BG-1: Background job started + # ========================================================================= + log_store2("BG-1", f""" +======================================== +BACKGROUND JOB STARTED: sync_sales_order +======================================== +request_id: {request_id} +store_name: {store_name} +Order ID: {order.get('id')} +Order Number: {order.get('name')} +frappe.local exists: {hasattr(frappe, 'local')} +""", store_name) + + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + # ========================================================================= + # STEP BG-2: Set store context (CRITICAL!) + # ========================================================================= + log_store2("BG-2", f""" +Setting store context in background worker... +Before: frappe.local.shopify_store_name = {getattr(frappe.local, 'shopify_store_name', 'NOT SET')} +""", store_name) + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("BG-2-OK", f""" +Store context set! +After: frappe.local.shopify_store_name = {frappe.local.shopify_store_name} +""", store_name) + else: + log_store2("BG-2-WARN", "store_name is None! Will default to Store 1 credentials!", store_name) + + # ========================================================================= + # STEP BG-3: Check if order already exists + # ========================================================================= + log_store2("BG-3", f"Checking if Sales Order already exists for Shopify Order ID: {order['id']}", store_name) + + existing_so = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}) + + if existing_so: + log_store2("BG-3-SKIP", f""" +Sales Order already exists! +Existing SO: {existing_so} +Shopify Order ID: {order['id']} +Skipping creation. +""", store_name) + create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + return + + log_store2("BG-3-OK", "No existing Sales Order found, proceeding with creation.", store_name) + + # ========================================================================= + # STEP BG-4: Process customer + # ========================================================================= + try: + log_store2("BG-4", "Processing customer...", store_name) + + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shopify_customer["billing_address"] = order.get("billing_address", "") + shopify_customer["shipping_address"] = order.get("shipping_address", "") + customer_id = shopify_customer.get("id") + + log_store2("BG-4a", f""" +Customer data: +customer_id: {customer_id} +email: {shopify_customer.get('email')} +has billing_address: {bool(order.get('billing_address'))} +has shipping_address: {bool(order.get('shipping_address'))} +""", store_name) + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + log_store2("BG-4b", f"Customer {customer_id} not synced, creating...", store_name) + customer.sync_customer(customer=shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} created.", store_name) + else: + log_store2("BG-4b", f"Customer {customer_id} already exists, updating addresses...", store_name) + customer.update_existing_addresses(shopify_customer) + log_store2("BG-4c", f"Customer {customer_id} addresses updated.", store_name) + else: + log_store2("BG-4-WARN", "No customer_id in order, will use default customer.", store_name) + + log_store2("BG-4-OK", "Customer processing complete.", store_name) + + except Exception as e: + log_store2("BG-4-EXCEPTION", f""" +Exception processing customer! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-5: Sync items/products + # ========================================================================= + try: + log_store2("BG-5", f""" +Syncing items from order... +Line items count: {len(order.get('line_items', []))} +Line items: {[item.get('title') for item in order.get('line_items', [])]} +""", store_name) + + create_items_if_not_exist(order) + + log_store2("BG-5-OK", "Items synced successfully.", store_name) + + except Exception as e: + log_store2("BG-5-EXCEPTION", f""" +Exception syncing items! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-6: Create Sales Order + # ========================================================================= + try: + log_store2("BG-6", "Creating Sales Order...", store_name) + + setting = frappe.get_doc(SETTING_DOCTYPE) + + log_store2("BG-6a", f""" +Settings loaded: +Company: {setting.company} +Warehouse: {setting.warehouse} +Sales Order Series: {setting.sales_order_series} +Default Customer: {setting.default_customer} +""", store_name) + + create_order(order, setting, store_name=store_name) + + log_store2("BG-6-OK", f""" +======================================== +SALES ORDER CREATED SUCCESSFULLY! +======================================== +Shopify Order ID: {order.get('id')} +Shopify Order Number: {order.get('name')} +Store: {store_name} +""", store_name) + + except Exception as e: + log_store2("BG-6-EXCEPTION", f""" +Exception creating Sales Order! +Error: {str(e)} +Type: {type(e).__name__} + +Traceback: +{traceback.format_exc()} +""", store_name) + create_shopify_log(status="Error", exception=e, rollback=True) + return + + # ========================================================================= + # STEP BG-7: Success! + # ========================================================================= + log_store2("BG-7", "Creating success log entry...", store_name) + create_shopify_log(status="Success") + log_store2("BG-7-OK", f""" +======================================== +BACKGROUND JOB COMPLETED SUCCESSFULLY! +======================================== +Shopify Order: {order.get('name')} +Store: {store_name} +""", store_name) + + +def create_order(order, setting, company=None, store_name=None): + """Create order with related documents.""" + # local import to avoid circular dependencies + from ecommerce_integrations.shopify.fulfillment import create_delivery_note + from ecommerce_integrations.shopify.invoice import create_sales_invoice + + log_store2("CREATE-1", "Inside create_order()", store_name) + + so = create_sales_order(order, setting, company, store_name=store_name) + + if so: + log_store2("CREATE-2", f"Sales Order created: {so.name}", store_name) + + if order.get("financial_status") == "paid": + log_store2("CREATE-3", "Order is paid, creating Sales Invoice...", store_name) + create_sales_invoice(order, setting, so) + log_store2("CREATE-3-OK", "Sales Invoice created.", store_name) + + if order.get("fulfillments"): + log_store2("CREATE-4", "Order has fulfillments, creating Delivery Note...", store_name) + create_delivery_note(order, setting, so) + log_store2("CREATE-4-OK", "Delivery Note created.", store_name) + else: + log_store2("CREATE-WARN", "create_sales_order returned None!", store_name) + + +def create_sales_order(shopify_order, setting, company=None, store_name=None): + """Create the actual Sales Order document.""" + + log_store2("SO-1", f""" +Creating Sales Order... +Shopify Order ID: {shopify_order.get('id')} +Shopify Order Number: {shopify_order.get('name')} +""", store_name) + + # Determine customer + customer = setting.default_customer + if shopify_order.get("customer", {}): + if customer_id := shopify_order.get("customer", {}).get("id"): + customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or customer + + log_store2("SO-2", f"Customer determined: {customer}", store_name) + + # Check if SO already exists + so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") + + if not so: + log_store2("SO-3", "Getting order items...", store_name) + + items = get_order_items( + shopify_order.get("line_items"), + setting, + getdate(shopify_order.get("created_at")), + taxes_inclusive=shopify_order.get("taxes_included"), + store_name=store_name, + ) + + log_store2("SO-3a", f"Items count: {len(items)}", store_name) + + if not items: + log_store2("SO-3-FAIL", "No items returned! Cannot create Sales Order.", store_name) + message = ( + "Following items exists in the shopify order but relevant records were" + " not found in the shopify Product master" + ) + create_shopify_log(status="Error", exception=message, rollback=True) + return "" + + log_store2("SO-4", "Getting order taxes...", store_name) + taxes = get_order_taxes(shopify_order, setting, items) + log_store2("SO-4a", f"Taxes count: {len(taxes)}", store_name) + + log_store2("SO-5", "Creating Sales Order document...", store_name) + + so = frappe.get_doc( + { + "doctype": "Sales Order", + "naming_series": setting.sales_order_series or "SO-Shopify-", + ORDER_ID_FIELD: str(shopify_order.get("id")), + ORDER_NUMBER_FIELD: shopify_order.get("name"), + "customer": customer, + "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), + "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), + "company": setting.company, + "selling_price_list": get_dummy_price_list(), + "ignore_pricing_rule": 1, + "items": items, + "taxes": taxes, + "tax_category": get_dummy_tax_category(), + } + ) + + if company: + so.update({"company": company, "status": "Draft"}) + + so.flags.ignore_mandatory = True + so.flags.shopiy_order_json = json.dumps(shopify_order) + + log_store2("SO-6", "Saving Sales Order...", store_name) + so.save(ignore_permissions=True) + log_store2("SO-6a", f"Sales Order saved: {so.name}", store_name) + + log_store2("SO-7", "Submitting Sales Order...", store_name) + so.submit() + log_store2("SO-7a", f"Sales Order submitted: {so.name}", store_name) + + if shopify_order.get("note"): + so.add_comment(text=f"Order Note: {shopify_order.get('note')}") + log_store2("SO-8", "Added order note as comment.", store_name) + + else: + log_store2("SO-EXISTS", f"Sales Order already exists: {so}", store_name) + so = frappe.get_doc("Sales Order", so) + + return so + + +def get_order_items(order_items, setting, delivery_date, taxes_inclusive, store_name=None): + """Get order items for Sales Order.""" + items = [] + all_product_exists = True + product_not_exists = [] + + log_store2("ITEMS-1", f"Processing {len(order_items)} line items...", store_name) + + for idx, shopify_item in enumerate(order_items): + product_id = shopify_item.get("product_id") + + log_store2(f"ITEMS-2-{idx}", f""" +Processing item {idx + 1}: + title: {shopify_item.get('title')} + product_id: {product_id} + variant_id: {shopify_item.get('variant_id')} + sku: {shopify_item.get('sku')} + quantity: {shopify_item.get('quantity')} + price: {shopify_item.get('price')} + product_exists: {shopify_item.get('product_exists')} +""", store_name) + + # Handle items without product_id (tips, samples, fees) + if not product_id: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-NOID", f"No product_id, mapped to: {item_code}", store_name) + if item_code: + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name") or shopify_item.get("title"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + continue + + # Original logic for items with product_id + if not shopify_item.get("product_exists"): + all_product_exists = False + product_not_exists.append( + {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")} + ) + log_store2(f"ITEMS-2-{idx}-NOTEXIST", f"Product does not exist in Shopify!", store_name) + continue + + if all_product_exists: + item_code = get_item_code(shopify_item) + log_store2(f"ITEMS-2-{idx}-CODE", f"Item code: {item_code}", store_name) + + if not item_code: + log_store2(f"ITEMS-2-{idx}-NOCODE", f"Could not get item_code!", store_name) + continue + + items.append( + { + "item_code": item_code, + "item_name": shopify_item.get("name"), + "rate": _get_item_price(shopify_item, taxes_inclusive), + "delivery_date": delivery_date, + "qty": shopify_item.get("quantity"), + "stock_uom": shopify_item.get("uom") or "Nos", + "warehouse": setting.warehouse, + ORDER_ITEM_DISCOUNT_FIELD: ( + _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) + ), + } + ) + else: + items = [] + + log_store2("ITEMS-3", f"Returning {len(items)} items", store_name) + return items + + +# Keep the rest of the functions unchanged but add store_name parameter where needed def _get_item_price(line_item, taxes_inclusive: bool) -> float: - price = flt(line_item.get("price")) - qty = cint(line_item.get("quantity")) + price = flt(line_item.get("price")) + qty = cint(line_item.get("quantity")) + total_discount = _get_total_discount(line_item) - # remove line item level discounts - total_discount = _get_total_discount(line_item) + if not taxes_inclusive: + return price - (total_discount / qty) - if not taxes_inclusive: - return price - (total_discount / qty) + total_taxes = 0.0 + for tax in line_item.get("tax_lines"): + total_taxes += flt(tax.get("price")) - total_taxes = 0.0 - for tax in line_item.get("tax_lines"): - total_taxes += flt(tax.get("price")) - - return price - (total_taxes + total_discount) / qty + return price - (total_taxes + total_discount) / qty def _get_total_discount(line_item) -> float: - discount_allocations = line_item.get("discount_allocations") or [] - return sum(flt(discount.get("amount")) for discount in discount_allocations) + discount_allocations = line_item.get("discount_allocations") or [] + return sum(flt(discount.get("amount")) for discount in discount_allocations) def get_order_taxes(shopify_order, setting, items): - taxes = [] - line_items = shopify_order.get("line_items") - - for line_item in line_items: - item_code = get_item_code(line_item) - for tax in line_item.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax.get("price"), - "included_in_print_rate": 0, - "cost_center": setting.cost_center, - "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, - "dont_recompute_tax": 1, - } - ) - - update_taxes_with_shipping_lines( - taxes, - shopify_order.get("shipping_lines"), - setting, - items, - taxes_inclusive=shopify_order.get("taxes_included"), - ) - - if cint(setting.consolidate_taxes): - taxes = consolidate_order_taxes(taxes) - - for row in taxes: - tax_detail = row.get("item_wise_tax_detail") - if isinstance(tax_detail, dict): - row["item_wise_tax_detail"] = json.dumps(tax_detail) - - return taxes + taxes = [] + line_items = shopify_order.get("line_items") + + for line_item in line_items: + item_code = get_item_code(line_item) + for tax in line_item.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax.get("price"), + "included_in_print_rate": 0, + "cost_center": setting.cost_center, + "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]}, + "dont_recompute_tax": 1, + } + ) + + update_taxes_with_shipping_lines( + taxes, + shopify_order.get("shipping_lines"), + setting, + items, + taxes_inclusive=shopify_order.get("taxes_included"), + ) + + if cint(setting.consolidate_taxes): + taxes = consolidate_order_taxes(taxes) + + for row in taxes: + tax_detail = row.get("item_wise_tax_detail") + if isinstance(tax_detail, dict): + row["item_wise_tax_detail"] = json.dumps(tax_detail) + + return taxes def consolidate_order_taxes(taxes): - tax_account_wise_data = {} - for tax in taxes: - account_head = tax["account_head"] - tax_account_wise_data.setdefault( - account_head, - { - "charge_type": "Actual", - "account_head": account_head, - "description": tax.get("description"), - "cost_center": tax.get("cost_center"), - "included_in_print_rate": 0, - "dont_recompute_tax": 1, - "tax_amount": 0, - "item_wise_tax_detail": {}, - }, - ) - tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) - if tax.get("item_wise_tax_detail"): - tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) - - return tax_account_wise_data.values() + tax_account_wise_data = {} + for tax in taxes: + account_head = tax["account_head"] + tax_account_wise_data.setdefault( + account_head, + { + "charge_type": "Actual", + "account_head": account_head, + "description": tax.get("description"), + "cost_center": tax.get("cost_center"), + "included_in_print_rate": 0, + "dont_recompute_tax": 1, + "tax_amount": 0, + "item_wise_tax_detail": {}, + }, + ) + tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount")) + if tax.get("item_wise_tax_detail"): + tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"]) + + return tax_account_wise_data.values() def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | None = None): - tax_title = str(tax.get("title")) + tax_title = str(tax.get("title")) - tax_account = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_account", - ) + tax_account = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + "tax_account", + ) - if not tax_account and charge_type: - tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) + if not tax_account and charge_type: + tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) - if not tax_account: - frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) + if not tax_account: + frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) - return tax_account + return tax_account def get_tax_account_description(tax): - tax_title = tax.get("title") + tax_title = tax.get("title") - tax_description = frappe.db.get_value( - "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, - "tax_description", - ) + tax_description = frappe.db.get_value( + "Shopify Tax Account", + {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + "tax_description", + ) - return tax_description + return tax_description def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False): - """Shipping lines represents the shipping details, - each such shipping detail consists of a list of tax_lines""" - shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item - for shipping_charge in shipping_lines: - if shipping_charge.get("price"): - shipping_discounts = shipping_charge.get("discount_allocations") or [] - total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) - - shipping_taxes = shipping_charge.get("tax_lines") or [] - total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) - - shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) - if bool(taxes_inclusive): - shipping_charge_amount -= total_tax - - if shipping_as_item: - items.append( - { - "item_code": setting.shipping_item, - "rate": shipping_charge_amount, - "delivery_date": items[-1]["delivery_date"] if items else nowdate(), - "qty": 1, - "stock_uom": "Nos", - "warehouse": setting.warehouse, - } - ) - else: - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) - or shipping_charge["title"], - "tax_amount": shipping_charge_amount, - "cost_center": setting.cost_center, - } - ) - - for tax in shipping_charge.get("tax_lines"): - taxes.append( - { - "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), - "description": ( - get_tax_account_description(tax) - or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" - ), - "tax_amount": tax["price"], - "cost_center": setting.cost_center, - "item_wise_tax_detail": { - setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] - } - if shipping_as_item - else {}, - "dont_recompute_tax": 1, - } - ) + shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item + for shipping_charge in shipping_lines: + if shipping_charge.get("price"): + shipping_discounts = shipping_charge.get("discount_allocations") or [] + total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts) + + shipping_taxes = shipping_charge.get("tax_lines") or [] + total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes) + + shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount) + if bool(taxes_inclusive): + shipping_charge_amount -= total_tax + + if shipping_as_item: + items.append( + { + "item_code": setting.shipping_item, + "rate": shipping_charge_amount, + "delivery_date": items[-1]["delivery_date"] if items else nowdate(), + "qty": 1, + "stock_uom": "Nos", + "warehouse": setting.warehouse, + } + ) + else: + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), + "description": get_tax_account_description(shipping_charge) + or shipping_charge["title"], + "tax_amount": shipping_charge_amount, + "cost_center": setting.cost_center, + } + ) + + for tax in shipping_charge.get("tax_lines"): + taxes.append( + { + "charge_type": "Actual", + "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "description": ( + get_tax_account_description(tax) + or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" + ), + "tax_amount": tax["price"], + "cost_center": setting.cost_center, + "item_wise_tax_detail": { + setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))] + } + if shipping_as_item + else {}, + "dont_recompute_tax": 1, + } + ) def get_sales_order(order_id): - """Get ERPNext sales order using shopify order id.""" - sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) - if sales_order: - return frappe.get_doc("Sales Order", sales_order) - - -def cancel_order(payload, request_id=None): - """Called by order/cancelled event. - - When shopify order is cancelled there could be many different someone handles it. - - Updates document with custom field showing order status. - - IF sales invoice / delivery notes are not generated against an order, then cancel it. + """Get ERPNext sales order using shopify order id.""" + sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + if sales_order: + return frappe.get_doc("Sales Order", sales_order) + + +def cancel_order(payload, request_id=None, store_name=None): + """Called by order/cancelled event.""" + frappe.set_user("Administrator") + frappe.flags.request_id = request_id + + if store_name: + frappe.local.shopify_store_name = store_name + log_store2("CANCEL-1", f"Cancelling order {payload.get('id')}", store_name) + + order = payload + + try: + order_id = order["id"] + order_status = order["financial_status"] + + sales_order = get_sales_order(order_id) + + if not sales_order: + log_store2("CANCEL-FAIL", f"Sales Order not found for {order_id}", store_name) + create_shopify_log(status="Invalid", message="Sales Order does not exist") + return + + sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) + delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) + + if sales_invoice: + frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) + + for dn in delivery_notes: + frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) + + if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: + sales_order.cancel() + log_store2("CANCEL-OK", f"Sales Order {sales_order.name} cancelled", store_name) + else: + frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) + log_store2("CANCEL-STATUS", f"Sales Order {sales_order.name} status updated", store_name) + + except Exception as e: + log_store2("CANCEL-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name) + create_shopify_log(status="Error", exception=e) + else: + create_shopify_log(status="Success") + + +def _normalize_address_for_comparison(addr_dict): + """Normalize address fields for comparison (trim, lowercase, handle None).""" + return { + "address_line1": (addr_dict.get("address_line1") or "").strip().lower(), + "address_line2": (addr_dict.get("address_line2") or "").strip().lower(), + "city": (addr_dict.get("city") or "").strip().lower(), + "state": (addr_dict.get("state") or "").strip().lower(), + "pincode": (addr_dict.get("pincode") or "").strip().lower(), + "country": (addr_dict.get("country") or "").strip().lower(), + "phone": (addr_dict.get("phone") or "").strip().lower(), + } + +def _get_customer_address_by_type(customer_name, address_type): + if not customer_name: + return None + + addresses = frappe.get_list( + "Address", + filters=[ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", customer_name], + ["Dynamic Link", "parenttype", "=", "Address"], + ["Address", "address_type", "=", address_type], + ], + fields=[ + "name", + "address_line1", + "address_line2", + "city", + "state", + "pincode", + "country", + "phone", + ], + limit=1, + ) + + return addresses[0] if addresses else None + +def update_sales_order(payload, request_id=None, store_name=None): + """Handle order updates from Shopify. + + Tracks ONLY necessary business changes: + - Line item changes (quantity, price, new items) with full details + - Price changes (grand_total, subtotal) + - Contact details (email, phone) + - Address changes (shipping, billing) + - Customer name changes + + Supports both Store 1 and Store 2. """ + order = payload frappe.set_user("Administrator") frappe.flags.request_id = request_id - - order = payload - + + # Set store context for Store 2 + if store_name: + frappe.local.shopify_store_name = store_name + try: - order_id = order["id"] - order_status = order["financial_status"] - - sales_order = get_sales_order(order_id) - - if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") + order_id = cstr(order.get("id")) + order_number = order.get("name", "") + shopify_customer = order.get("customer", {}) if order.get("customer") else {} + customer_id = shopify_customer.get("id") + + # Check if Sales Order exists + sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id}) + + # Early exit: If order doesn't exist, create it and return + # (This prevents checking for metadata-only updates on non-existent orders) + + if not sales_order_name: + # Order doesn't exist, create it + create_shopify_log( + status="Info", + message=f"Order {order_number} not found, creating new order", + request_data=order, + response_data={"action": "create_new_order"} + ) + sync_sales_order(payload, request_id, store_name=store_name) return - - sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) - delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}) - - if sales_invoice: - frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status) - - for dn in delivery_notes: - frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status) - - if not sales_invoice and not delivery_notes and sales_order.docstatus == 1: - sales_order.cancel() + + # Order exists, detect changes + sales_order = frappe.get_doc("Sales Order", sales_order_name) + changes = {} + + # FIRST: Quick check for critical business changes (amounts, items) + # If these haven't changed, this is likely just a metadata/timeline update + # Skip expensive address/contact checks for metadata-only updates + has_critical_changes = False + + # Compare grand total (with tolerance for floating point) + old_total = flt(sales_order.grand_total) + new_total = flt(order.get("total_price", 0)) + if abs(old_total - new_total) > 0.01: # Only if difference > 1 cent + changes["grand_total"] = { + "old": old_total, + "new": new_total, + "difference": new_total - old_total + } + has_critical_changes = True + + # Compare subtotal (with tolerance) + old_subtotal = flt(sales_order.total) + new_subtotal = flt(order.get("subtotal_price", 0)) + if abs(old_subtotal - new_subtotal) > 0.01: # Only if difference > 1 cent + changes["subtotal"] = { + "old": old_subtotal, + "new": new_subtotal, + "difference": new_subtotal - old_subtotal + } + has_critical_changes = True + + # Compare line items with improved matching and full details + line_item_changes = [] + shopify_items_by_id = {str(item.get("id", "")): item for item in order.get("line_items", [])} + shopify_items_by_sku = {} # Group by SKU for fallback matching + + for item in order.get("line_items", []): + sku = item.get("sku", "") + if sku: + if sku not in shopify_items_by_sku: + shopify_items_by_sku[sku] = [] + shopify_items_by_sku[sku].append(item) + + matched_shopify_ids = set() + matched_so_items = set() # Track which SO items we've matched + + # Match existing Sales Order items with Shopify items + for idx, so_item in enumerate(sales_order.items): + item_code = so_item.item_code + shopify_item_id = str(so_item.get("shopify_line_item_id", "")) + matched_item = None + item_changes = {} + + # Try matching by shopify_line_item_id first (most reliable) + if shopify_item_id and shopify_item_id in shopify_items_by_id: + matched_item = shopify_items_by_id[shopify_item_id] + matched_shopify_ids.add(shopify_item_id) + matched_so_items.add(idx) + else: + # Fallback: match by item_code/SKU + quantity + rate + so_sku = item_code + old_qty = cint(so_item.qty) + old_rate = flt(so_item.rate) + + if so_sku in shopify_items_by_sku: + for shopify_item in shopify_items_by_sku[so_sku]: + shopify_item_id_str = str(shopify_item.get("id", "")) + if shopify_item_id_str in matched_shopify_ids: + continue + + new_qty = cint(shopify_item.get("quantity", 0)) + new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False))) + + # Match if quantity and rate are close (within tolerance) + if old_qty == new_qty and abs(old_rate - new_rate) <= 0.01: + matched_item = shopify_item + matched_shopify_ids.add(shopify_item_id_str) + matched_so_items.add(idx) + break + + # If matched, compare details + if matched_item: + # Compare quantity + old_qty = cint(so_item.qty) + new_qty = cint(matched_item.get("quantity", 0)) + if old_qty != new_qty: + item_changes["quantity"] = {"old": old_qty, "new": new_qty} + + # Compare rate (with tolerance) + old_rate = flt(so_item.rate) + new_rate = flt(_get_item_price(matched_item, order.get("taxes_included", False))) + if abs(old_rate - new_rate) > 0.01: + item_changes["rate"] = {"old": old_rate, "new": new_rate} + + # Compare item title/name (in case product name changed) + old_title = so_item.item_name or "" + new_title = matched_item.get("title", "") or matched_item.get("name", "") + if old_title != new_title: + item_changes["title"] = {"old": old_title, "new": new_title} + + if item_changes: + line_item_changes.append({ + "item_code": item_code, + "sku": matched_item.get("sku", ""), + "title": matched_item.get("title", ""), + "shopify_line_item_id": str(matched_item.get("id", "")), + "changes": item_changes + }) + + # Check for new items (items in Shopify that don't exist in Sales Order) + for shopify_item in order.get("line_items", []): + shopify_item_id_str = str(shopify_item.get("id", "")) + if shopify_item_id_str not in matched_shopify_ids: + # This is a genuinely new item + line_item_changes.append({ + "action": "added", + "title": shopify_item.get("title", ""), + "sku": shopify_item.get("sku", ""), + "quantity": cint(shopify_item.get("quantity", 0)), + "rate": flt(_get_item_price(shopify_item, order.get("taxes_included", False))), + "shopify_line_item_id": shopify_item_id_str, + "product_id": shopify_item.get("product_id"), + "variant_id": shopify_item.get("variant_id"), + }) + + if line_item_changes: + changes["line_items"] = line_item_changes + has_critical_changes = True + + # Track notes (but don't trigger logs for notes alone) + old_note = sales_order.get("note", "") or "" + new_note = order.get("note", "") or "" + if old_note.strip() != new_note.strip(): + changes["note"] = { + "old": old_note, + "new": new_note + } + # Note: NOT setting has_critical_changes - notes won't trigger logs alone + + # Track fulfillment status (but don't trigger logs for fulfillment status alone) + # Fulfillment status: unfulfilled, partial, fulfilled + old_fulfillment_status = "" + # Get from order fulfillments + if order.get("fulfillments"): + # Check if all items are fulfilled + total_quantity = sum(item.get("quantity", 0) for item in order.get("line_items", [])) + fulfilled_quantity = sum( + sum(f_item.get("quantity", 0) for f_item in fulfillment.get("line_items", [])) + for fulfillment in order.get("fulfillments", []) + ) + if fulfilled_quantity == 0: + new_fulfillment_status = "unfulfilled" + elif fulfilled_quantity >= total_quantity: + new_fulfillment_status = "fulfilled" + else: + new_fulfillment_status = "partial" + else: + new_fulfillment_status = "unfulfilled" + + # Track fulfillment status change + if new_fulfillment_status: + changes["fulfillment_status"] = { + "old": old_fulfillment_status or "unfulfilled", + "new": new_fulfillment_status + } + # Note: NOT setting has_critical_changes - fulfillment status won't trigger logs alone + + # Track tags (but don't trigger logs for tags alone) + new_tags = order.get("tags", "") or "" # Tags are stored as comma-separated string in Shopify + if new_tags: + changes["tags"] = { + "new": new_tags + } + # Note: NOT setting has_critical_changes - tags won't trigger logs alone + + # Track updated_at (but don't trigger logs for updated_at alone) + # updated_at is just a timestamp that changes on every update + updated_at = order.get("updated_at", "") + if updated_at: + changes["updated_at"] = { + "new": updated_at + } + # Note: NOT setting has_critical_changes - updated_at won't trigger logs alone + + # Early exit: If no critical changes (amounts, items, notes, fulfillment), skip address/contact checks + # This filters out metadata-only updates (timeline events, emails, ParcelWILL, Katana, etc.) + # BUT: updated_at changes are NOT tracked - it's just a timestamp that changes on every update + if not has_critical_changes: + # Still check customer name change (important business change) + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + has_critical_changes = True + + # If still no critical changes, this is a metadata-only update - skip it entirely + # (ParcelWILL emails, Katana updates, timeline events, etc. don't change business data) + # Note: updated_at is NOT tracked - it's just a timestamp that changes on every update + if not has_critical_changes: + return + + # Compare customer name (if we haven't already checked it above) + if "customer" not in changes: + old_customer = sales_order.customer + if customer_id: + new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") + if old_customer != new_customer and new_customer: + changes["customer"] = { + "old": old_customer, + "new": new_customer + } + + # Compare customer email + shopify_email = shopify_customer.get("email", "") + if shopify_email and sales_order.customer: + # Get customer email from ERPNext Contact (using Dynamic Link) + contact_filters = [ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", sales_order.customer], + ["Dynamic Link", "parenttype", "=", "Contact"], + ] + contacts = frappe.get_all("Contact", filters=contact_filters, fields=["email_id"], limit=1) + old_email = contacts[0].get("email_id", "") if contacts else "" + if old_email != shopify_email: + changes["customer_email"] = { + "old": old_email, + "new": shopify_email + } + + # Compare customer phone + shopify_phone = shopify_customer.get("phone", "") or shopify_customer.get("default_address", {}).get("phone", "") + if shopify_phone and sales_order.customer: + # Get customer phone from ERPNext Contact (using Dynamic Link) + contact_filters = [ + ["Dynamic Link", "link_doctype", "=", "Customer"], + ["Dynamic Link", "link_name", "=", sales_order.customer], + ["Dynamic Link", "parenttype", "=", "Contact"], + ] + contacts = frappe.get_all("Contact", filters=contact_filters, fields=["phone", "mobile_no"], limit=1) + contact = contacts[0] if contacts else None + if contact: + old_phone = contact.get("phone", "") or contact.get("mobile_no", "") + if old_phone != shopify_phone: + changes["customer_phone"] = { + "old": old_phone, + "new": shopify_phone + } + + # Compare shipping address + shipping_address = order.get("shipping_address", {}) + if shipping_address and sales_order.customer: + # Get shipping address from Customer + shipping_addr_doc = _get_customer_address_by_type( + sales_order.customer, "Shipping" + ) + + if shipping_addr_doc: + old_addr = { + "address_line1": shipping_addr_doc.get("address_line1", ""), + "address_line2": shipping_addr_doc.get("address_line2", ""), + "city": shipping_addr_doc.get("city", ""), + "state": shipping_addr_doc.get("state", ""), + "pincode": shipping_addr_doc.get("pincode", ""), + "country": shipping_addr_doc.get("country", ""), + "phone": shipping_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": shipping_address.get("address1", ""), + "address_line2": shipping_address.get("address2", ""), + "city": shipping_address.get("city", ""), + "state": shipping_address.get("province", ""), + "pincode": shipping_address.get("zip", ""), + "country": shipping_address.get("country", ""), + "phone": shipping_address.get("phone", ""), + } + + # Compare normalized addresses + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["shipping_address"] = { + "old": old_addr, + "new": new_addr + } + + # Compare billing address + # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + billing_address = order.get("billing_address") + billing_is_same_as_shipping = False + + # Check if billing is explicitly null/empty (Shopify "same as shipping" case) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + billing_is_same_as_shipping = True + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + if billing_address and sales_order.customer: + # Get billing address from Customer + billing_addr_doc = _get_customer_address_by_type( + sales_order.customer, "Billing" + ) + + if billing_addr_doc: + old_addr = { + "address_line1": billing_addr_doc.get("address_line1", ""), + "address_line2": billing_addr_doc.get("address_line2", ""), + "city": billing_addr_doc.get("city", ""), + "state": billing_addr_doc.get("state", ""), + "pincode": billing_addr_doc.get("pincode", ""), + "country": billing_addr_doc.get("country", ""), + "phone": billing_addr_doc.get("phone", ""), + } + new_addr = { + "address_line1": billing_address.get("address1", ""), + "address_line2": billing_address.get("address2", ""), + "city": billing_address.get("city", ""), + "state": billing_address.get("province", ""), + "pincode": billing_address.get("zip", ""), + "country": billing_address.get("country", ""), + "phone": billing_address.get("phone", ""), + } + + # Compare normalized addresses + old_normalized = _normalize_address_for_comparison(old_addr) + new_normalized = _normalize_address_for_comparison(new_addr) + + if old_normalized != new_normalized: + changes["billing_address"] = { + "old": old_addr, + "new": new_addr, + "is_same_as_shipping": billing_is_same_as_shipping + } + + # Skip logging if no changes detected + if not changes: + return + + # Process the update + if sales_order.docstatus == 2: # Cancelled + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + create_shopify_log( + status="Invalid", + message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.", + request_data=order, + response_data={"change_details": changes} + ) + return + + # Update customer if changed - handle billing address properly + shopify_customer = order.get("customer") if order.get("customer") is not None else {} + shipping_address = order.get("shipping_address", {}) + billing_address = order.get("billing_address") + + # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty + # (Shopify indicates "same as shipping" by having billing_address as null) + if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())): + # Billing is same as shipping - use shipping address + if shipping_address: + billing_address = shipping_address + # Fallback to customer default address only if no shipping + elif not billing_address: + billing_address = shopify_customer.get("default_address", {}) + + shopify_customer["billing_address"] = billing_address + shopify_customer["shipping_address"] = shipping_address + + if customer_id: + customer = ShopifyCustomer(customer_id=customer_id) + if not customer.is_synced(): + customer.sync_customer(customer=shopify_customer) + else: + customer.update_existing_addresses(shopify_customer) + + # Ensure items exist + create_items_if_not_exist(order) + + # Get updated items and taxes + setting = frappe.get_doc(SETTING_DOCTYPE) + items = get_order_items( + order.get("line_items"), + setting, + getdate(order.get("created_at")) or getdate(sales_order.transaction_date), + taxes_inclusive=order.get("taxes_included"), + store_name=store_name, + ) + + if not items: + create_shopify_log( + status="Error", + message="Cannot update order: items not found in product master", + request_data=order, + response_data={"change_details": changes}, + rollback=True + ) + return + + taxes = get_order_taxes(order, setting, items) + + # Update customer name + customer_name = setting.default_customer + if shopify_customer.get("id"): + customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer + + # Determine if we have real business changes (not just metadata) + # Track: prices, line items, contact details, addresses, customer name + # Note: tags, notes, fulfillment_status, updated_at are tracked but DON'T trigger logs alone + has_real_changes = any( + key in changes for key in [ + "grand_total", "subtotal", "line_items", + "customer", "customer_email", "customer_phone", + "shipping_address", "billing_address" + ] + ) + + # Update the order + if sales_order.docstatus == 1: # Submitted + # For submitted orders, only update status and notes if there are REAL changes + # Don't log if only metadata changed (fulfillment status, tracking, updated_at, etc.) + if has_real_changes: + frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status")) + + if order.get("note"): + sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}") + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.", + request_data=order, + response_data={"change_details": changes} + ) + else: + # If no real changes, silently return (metadata-only update) + return else: - frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) + # Draft order - can update fully + sales_order.update({ + "customer": customer_name, + "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date, + "delivery_date": getdate(order.get("created_at")) or sales_order.delivery_date, + "items": items, + "taxes": taxes, + }) + + if order.get("name") != sales_order.get(ORDER_NUMBER_FIELD): + sales_order.set(ORDER_NUMBER_FIELD, order.get("name")) + + sales_order.flags.ignore_mandatory = True + sales_order.flags.shopiy_order_json = json.dumps(order) + sales_order.save(ignore_permissions=True) + + if order.get("note"): + sales_order.add_comment(text=f"Order Note: {order.get('note')}") + + create_shopify_log( + status="Success", + message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected", + request_data=order, + response_data={"change_details": changes} + ) + # Trigger update detection on all linked documents (only if real changes detected) + if sales_order_name and has_real_changes: + try: + old_form_dict = dict(frappe.form_dict) + frappe.form_dict["doctype"] = "Sales Order" + frappe.form_dict["docname"] = sales_order_name + + if frappe.db.exists("Server Script", "Get Shopify Order Updates"): + script_doc = frappe.get_doc("Server Script", "Get Shopify Order Updates") + script_doc.execute_method() + + detection_result = frappe.response.get("message") or {} + detection_has_changes = detection_result.get("has_changes", False) + + if detection_has_changes: + frappe.logger().info( + "Shopify update detection: Changes flagged on all linked docs for SO {}".format( + sales_order_name + ) + ) + + frappe.form_dict = old_form_dict + except Exception as detection_error: + frappe.logger().error( + "Shopify update detection failed for SO {}: {}".format( + sales_order_name, str(detection_error) + ) + ) + frappe.form_dict = old_form_dict if "old_form_dict" in locals() else frappe.form_dict + except Exception as e: - create_shopify_log(status="Error", exception=e) - else: - create_shopify_log(status="Success") + create_shopify_log( + status="Error", + exception=e, + message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}", + request_data=order, + response_data={"error": str(e)}, + rollback=True + ) @temp_shopify_session def sync_old_orders(): - shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) - if not cint(shopify_setting.sync_old_orders): - return + shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) + if not cint(shopify_setting.sync_old_orders): + return - orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) + orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) - for order in orders: - log = create_shopify_log( - method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True - ) - sync_sales_order(order, request_id=log.name) + for order in orders: + log = create_shopify_log( + method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True + ) + sync_sales_order(order, request_id=log.name) - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) - shopify_setting.sync_old_orders = 0 - shopify_setting.save() + shopify_setting = frappe.get_doc(SETTING_DOCTYPE) + shopify_setting.sync_old_orders = 0 + shopify_setting.save() def _fetch_old_orders(from_time, to_time): - """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" - - from_time = get_datetime(from_time).astimezone().isoformat() - to_time = get_datetime(to_time).astimezone().isoformat() - orders_iterator = PaginatedIterator( - Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) - ) - - for orders in orders_iterator: - for order in orders: - # Using generator instead of fetching all at once is better for - # avoiding rate limits and reducing resource usage. - yield order.to_dict() + from_time = get_datetime(from_time).astimezone().isoformat() + to_time = get_datetime(to_time).astimezone().isoformat() + orders_iterator = PaginatedIterator( + Order.find(created_at_min=from_time, created_at_max=to_time, limit=250) + ) + + for orders in orders_iterator: + for order in orders: + yield order.to_dict() \ No newline at end of file diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..effa57cf8 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -1,570 +1,654 @@ -from typing import Optional - -import frappe -from frappe import _, msgprint -from frappe.utils import cint, cstr -from frappe.utils.nestedset import get_root_of -from shopify.resources import Product, Variant - -from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item -from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import ( - ITEM_SELLING_RATE_FIELD, - MODULE_NAME, - SETTING_DOCTYPE, - SHOPIFY_VARIANTS_ATTR_LIST, - SUPPLIER_ID_FIELD, - WEIGHT_TO_ERPNEXT_UOM_MAP, -) -from ecommerce_integrations.shopify.utils import create_shopify_log - - -class ShopifyProduct: - def __init__( - self, - product_id: str, - variant_id: str | None = None, - sku: str | None = None, - has_variants: int | None = 0, - ): - self.product_id = str(product_id) - self.variant_id = str(variant_id) if variant_id else None - self.sku = str(sku) if sku else None - self.has_variants = has_variants - self.setting = frappe.get_doc(SETTING_DOCTYPE) - - if not self.setting.is_enabled(): - frappe.throw(_("Can not create Shopify product when integration is disabled.")) - - def is_synced(self) -> bool: - return ecommerce_item.is_synced( - MODULE_NAME, - integration_item_code=self.product_id, - variant_id=self.variant_id, - sku=self.sku, - ) - - def get_erpnext_item(self): - return ecommerce_item.get_erpnext_item( - MODULE_NAME, - integration_item_code=self.product_id, - variant_id=self.variant_id, - sku=self.sku, - has_variants=self.has_variants, - ) - - @temp_shopify_session - def sync_product(self): - if not self.is_synced(): - shopify_product = Product.find(self.product_id) - product_dict = shopify_product.to_dict() - self._make_item(product_dict) - - def _make_item(self, product_dict): - _add_weight_details(product_dict) - - warehouse = self.setting.warehouse - - if _has_variants(product_dict): - self.has_variants = 1 - attributes = self._create_attribute(product_dict) - self._create_item(product_dict, warehouse, 1, attributes) - self._create_item_variants(product_dict, warehouse, attributes) - - else: - product_dict["variant_id"] = product_dict["variants"][0]["id"] - self._create_item(product_dict, warehouse) - - def _create_attribute(self, product_dict): - attribute = [] - for attr in product_dict.get("options"): - if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): - frappe.get_doc( - { - "doctype": "Item Attribute", - "attribute_name": attr.get("name"), - "item_attribute_values": [ - {"attribute_value": attr_value, "abbr": attr_value} - for attr_value in attr.get("values") - ], - } - ).insert() - attribute.append({"attribute": attr.get("name")}) - - else: - # check for attribute values - item_attr = frappe.get_doc("Item Attribute", attr.get("name")) - if not item_attr.numeric_values: - self._set_new_attribute_values(item_attr, attr.get("values")) - item_attr.save() - attribute.append({"attribute": attr.get("name")}) - - else: - attribute.append( - { - "attribute": attr.get("name"), - "from_range": item_attr.get("from_range"), - "to_range": item_attr.get("to_range"), - "increment": item_attr.get("increment"), - "numeric_values": item_attr.get("numeric_values"), - } - ) - - return attribute - - def _set_new_attribute_values(self, item_attr, values): - for attr_value in values: - if not any( - (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) - for d in item_attr.item_attribute_values - ): - item_attr.append("item_attribute_values", {"attribute_value": attr_value, "abbr": attr_value}) - - def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, variant_of=None): - item_dict = { - "variant_of": variant_of, - "is_stock_item": 1, - "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), - "item_name": product_dict.get("title", "").strip(), - "description": product_dict.get("body_html") or product_dict.get("title"), - "item_group": self._get_item_group(product_dict.get("product_type")), - "has_variants": has_variant, - "attributes": attributes or [], - "stock_uom": product_dict.get("uom") or _("Nos"), - "sku": product_dict.get("sku") or _get_sku(product_dict), - "default_warehouse": warehouse, - "image": _get_item_image(product_dict), - "weight_uom": WEIGHT_TO_ERPNEXT_UOM_MAP[product_dict.get("weight_unit")], - "weight_per_unit": product_dict.get("weight"), - "default_supplier": self._get_supplier(product_dict), - } - - integration_item_code = product_dict["id"] # shopify product_id - variant_id = product_dict.get("variant_id", "") # shopify variant_id if has variants - sku = item_dict["sku"] - - if not _match_sku_and_link_item( - item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant - ): - ecommerce_item.create_ecommerce_item( - MODULE_NAME, - integration_item_code, - item_dict, - variant_id=variant_id, - sku=sku, - variant_of=variant_of, - has_variants=has_variant, - ) - - def _create_item_variants(self, product_dict, warehouse, attributes): - template_item = ecommerce_item.get_erpnext_item( - MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 - ) - - if template_item: - for variant in product_dict.get("variants"): - shopify_item_variant = { - "id": product_dict.get("id"), - "variant_id": variant.get("id"), - "item_code": variant.get("id"), - "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), - "product_type": product_dict.get("product_type"), - "sku": variant.get("sku"), - "uom": template_item.stock_uom or _("Nos"), - "item_price": variant.get("price"), - "weight_unit": variant.get("weight_unit"), - "weight": variant.get("weight"), - } - - for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): - if variant.get(variant_attr): - attributes[i].update( - { - "attribute_value": self._get_attribute_value( - variant.get(variant_attr), attributes[i] - ) - } - ) - self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) - - def _get_attribute_value(self, variant_attr_val, attribute): - attribute_value = frappe.db.sql( - """select attribute_value from `tabItem Attribute Value` - where parent = %s and (abbr = %s or attribute_value = %s)""", - (attribute["attribute"], variant_attr_val, variant_attr_val), - as_list=1, - ) - return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) - - def _get_item_group(self, product_type=None): - parent_item_group = get_root_of("Item Group") - - if not product_type: - return parent_item_group - - if frappe.db.get_value("Item Group", product_type, "name"): - return product_type - item_group = frappe.get_doc( - { - "doctype": "Item Group", - "item_group_name": product_type, - "parent_item_group": parent_item_group, - "is_group": "No", - } - ).insert() - return item_group.name - - def _get_supplier(self, product_dict): - if product_dict.get("vendor"): - supplier = frappe.db.sql( - f"""select name from tabSupplier - where name = %s or {SUPPLIER_ID_FIELD} = %s """, - (product_dict.get("vendor"), product_dict.get("vendor").lower()), - as_list=1, - ) - - if supplier: - return product_dict.get("vendor") - supplier = frappe.get_doc( - { - "doctype": "Supplier", - "supplier_name": product_dict.get("vendor"), - SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), - "supplier_group": self._get_supplier_group(), - } - ).insert() - return supplier.name - else: - return "" - - def _get_supplier_group(self): - supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) - if not supplier_group: - supplier_group = frappe.get_doc( - {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} - ).insert() - return supplier_group.name - return supplier_group - - -def _add_weight_details(product_dict): - variants = product_dict.get("variants") - if variants: - product_dict["weight"] = variants[0]["weight"] - product_dict["weight_unit"] = variants[0]["weight_unit"] - - -def _has_variants(product_dict) -> bool: - options = product_dict.get("options") - return bool(options and "Default Title" not in options[0]["values"]) - - -def _get_sku(product_dict): - if product_dict.get("variants"): - return product_dict.get("variants")[0].get("sku") - return "" - - -def _get_item_image(product_dict): - if product_dict.get("image"): - return product_dict.get("image").get("src") - return None - - -def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: - """Tries to match new item with existing item using Shopify SKU == item_code. - - Returns true if matched and linked. - """ - sku = item_dict["sku"] - if not sku or variant_of or has_variant: - return False - - item_name = frappe.db.get_value("Item", {"item_code": sku}) - if item_name: - try: - ecommerce_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "integration": MODULE_NAME, - "erpnext_item_code": item_name, - "integration_item_code": product_id, - "has_variants": 0, - "variant_id": cstr(variant_id), - "sku": sku, - } - ) - - ecommerce_item.insert() - return True - except Exception: - return False - - -def create_items_if_not_exist(order): - """Using shopify order, sync all items that are not already synced.""" - for item in order.get("line_items", []): - product_id = item["product_id"] - variant_id = item.get("variant_id") - sku = item.get("sku") - product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) - - if not product.is_synced(): - product.sync_product() - - -def get_item_code(shopify_item): - """Get item code using shopify_item dict. - - Item should contain both product_id and variant_id.""" - - item = ecommerce_item.get_erpnext_item( - integration=MODULE_NAME, - integration_item_code=shopify_item.get("product_id"), - variant_id=shopify_item.get("variant_id"), - sku=shopify_item.get("sku"), - ) - if item: - return item.item_code - - -@temp_shopify_session -def upload_erpnext_item(doc, method=None): - """This hook is called when inserting new or updating existing `Item`. - - New items are pushed to shopify and changes to existing items are - updated depending on what is configured in "Shopify Setting" doctype. - """ - template_item = item = doc # alias for readability - # a new item recieved from ecommerce_integrations is being inserted - if item.flags.from_integration: - return - - setting = frappe.get_doc(SETTING_DOCTYPE) - - if not setting.is_enabled() or not setting.upload_erpnext_items: - return - - if frappe.flags.in_import: - return - - if item.has_variants: - return - - if len(item.attributes) > 3: - msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) - return - - if doc.variant_of and not setting.upload_variants_as_items: - msgprint(_("Enable variant sync in setting to upload item to Shopify.")) - return - - if item.variant_of: - template_item = frappe.get_doc("Item", item.variant_of) - - product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - is_new_product = not bool(product_id) - - if is_new_product: - product = Product() - product.published = False - product.status = "active" if setting.sync_new_item_as_active else "draft" - - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - is_successful = product.save() - - if is_successful: - update_default_variant_properties( - product, - sku=template_item.item_code, - price=template_item.get(ITEM_SELLING_RATE_FIELD), - is_stock_item=template_item.is_stock_item, - ) - if item.variant_of: - product.options = [] - product.variants = [] - variant_attributes = { - "title": template_item.item_name, - "sku": item.item_code, - "price": item.get(ITEM_SELLING_RATE_FIELD), - } - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( - { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw( - _("Shopify Error: Missing value for attribute {}").format(attr.attribute) - ) - product.variants.append(Variant(variant_attributes)) - - product.save() # push variant - - ecom_items = list(set([item, template_item])) - for d in ecom_items: - ecom_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": d.name, - "integration": MODULE_NAME, - "integration_item_code": str(product.id), - "variant_id": "" if d.has_variants else str(product.variants[0].id), - "sku": "" if d.has_variants else str(product.variants[0].sku), - "has_variants": d.has_variants, - "variant_of": d.variant_of, - } - ) - ecom_item.insert() - - write_upload_log(status=is_successful, product=product, item=item) - elif setting.update_shopify_item_on_update: - product = Product.find(product_id) - if product: - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - if not item.variant_of: - update_default_variant_properties( - product, - is_stock_item=template_item.is_stock_item, - price=item.get(ITEM_SELLING_RATE_FIELD), - ) - else: - variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} - product.options = [] - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( - { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw( - _("Shopify Error: Missing value for attribute {}").format(attr.attribute) - ) - product.variants.append(Variant(variant_attributes)) - - is_successful = product.save() - if is_successful and item.variant_of: - map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - - write_upload_log(status=is_successful, product=product, item=item, action="Updated") - - -def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): - variant_product_id = frappe.db.get_value( - "Ecommerce Item", - {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, - "integration_item_code", - ) - if not variant_product_id: - for variant in shopify_product.variants: - if ( - variant.option1 == variant_attributes.get("option1") - and variant.option2 == variant_attributes.get("option2") - and variant.option3 == variant_attributes.get("option3") - ): - variant_product_id = str(variant.id) - if not frappe.flags.in_test: - frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": erpnext_item.name, - "integration": MODULE_NAME, - "integration_item_code": str(shopify_product.id), - "variant_id": variant_product_id, - "sku": str(variant.sku), - "variant_of": erpnext_item.variant_of, - } - ).insert() - break - if not variant_product_id: - msgprint(_("Shopify: Couldn't sync item variant.")) - return variant_product_id - - -def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): - """Map erpnext fields to shopify, called both when updating and creating new products.""" - - shopify_product.title = erpnext_item.item_name - shopify_product.body_html = erpnext_item.description - shopify_product.product_type = erpnext_item.item_group - - if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): - # reverse lookup for key - uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) - shopify_product.weight = erpnext_item.weight_per_unit - shopify_product.weight_unit = uom - - if erpnext_item.disabled: - shopify_product.status = "draft" - shopify_product.published = False - msgprint(_("Status of linked Shopify product is changed to Draft.")) - - -def get_shopify_weight_uom(erpnext_weight_uom: str) -> str: - for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): - if erpnext_uom == erpnext_weight_uom: - return shopify_uom - - -def update_default_variant_properties( - shopify_product: Product, - is_stock_item: bool, - sku: str | None = None, - price: float | None = None, -): - """Shopify creates default variant upon saving the product. - - Some item properties are supposed to be updated on the default variant. - Input: saved shopify_product, sku and price - """ - default_variant: Variant = shopify_product.variants[0] - - # this will create Inventory item and qty will be updated by scheduled job. - if is_stock_item: - default_variant.inventory_management = "shopify" - - if price is not None: - default_variant.price = price - if sku is not None: - default_variant.sku = sku - - -def write_upload_log(status: bool, product: Product, item, action="Created") -> None: - if not status: - msg = _("Failed to upload item to Shopify") + "
" - msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) - msgprint(msg, title="Note", indicator="orange") - - create_shopify_log( - status="Error", - request_data=product.to_dict(), - message=msg, - method="upload_erpnext_item", - ) - else: - create_shopify_log( - status="Success", - request_data=product.to_dict(), - message=f"{action} Item: {item.name}, shopify product: {product.id}", - method="upload_erpnext_item", - ) +from typing import Optional + +import frappe +from frappe import _, msgprint +from frappe.utils import cint, cstr +from frappe.utils.nestedset import get_root_of +from shopify.resources import Product, Variant + +from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item +from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.constants import ( + ITEM_SELLING_RATE_FIELD, + MODULE_NAME, + SETTING_DOCTYPE, + SHOPIFY_VARIANTS_ATTR_LIST, + SUPPLIER_ID_FIELD, + WEIGHT_TO_ERPNEXT_UOM_MAP, +) +from ecommerce_integrations.shopify.utils import create_shopify_log + + +def get_shopify_fallback_item(title): + """Map Shopify line items without product_id to appropriate fallback items. + + Args: + title (str): Line item title from Shopify + + Returns: + str: ERPNext Item code + """ + if not title: + return "SHOPIFY-MISC" + + title_lower = title.lower() + + # Map based on title keywords + if "tip" in title_lower: + return "SHOPIFY-TIP" + elif "sample" in title_lower: + return "SHOPIFY-SAMPLE" + elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower: + return "SHOPIFY-RUSH-FEE" + elif "adjustment" in title_lower or "price adjustment" in title_lower: + return "SHOPIFY-ADJUSTMENT" + else: + return "SHOPIFY-MISC" + + +class ShopifyProduct: + def __init__( + self, + product_id: str, + variant_id: str | None = None, + sku: str | None = None, + has_variants: int | None = 0, + ): + self.product_id = str(product_id) + self.variant_id = str(variant_id) if variant_id else None + self.sku = str(sku) if sku else None + self.has_variants = has_variants + self.setting = frappe.get_doc(SETTING_DOCTYPE) + + if not self.setting.is_enabled(): + frappe.throw(_("Can not create Shopify product when integration is disabled.")) + + def is_synced(self) -> bool: + return ecommerce_item.is_synced( + MODULE_NAME, + integration_item_code=self.product_id, + variant_id=self.variant_id, + sku=self.sku, + ) + + def get_erpnext_item(self): + return ecommerce_item.get_erpnext_item( + MODULE_NAME, + integration_item_code=self.product_id, + variant_id=self.variant_id, + sku=self.sku, + has_variants=self.has_variants, + ) + + @temp_shopify_session + def sync_product(self): + if not self.is_synced(): + shopify_product = Product.find(self.product_id) + product_dict = shopify_product.to_dict() + self._make_item(product_dict) + + def _make_item(self, product_dict): + _add_weight_details(product_dict) + + warehouse = self.setting.warehouse + + if _has_variants(product_dict): + self.has_variants = 1 + attributes = self._create_attribute(product_dict) + self._create_item(product_dict, warehouse, 1, attributes) + self._create_item_variants(product_dict, warehouse, attributes) + + else: + product_dict["variant_id"] = product_dict["variants"][0]["id"] + self._create_item(product_dict, warehouse) + + def _create_attribute(self, product_dict): + attribute = [] + for attr in product_dict.get("options"): + if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"): + frappe.get_doc( + { + "doctype": "Item Attribute", + "attribute_name": attr.get("name"), + "item_attribute_values": [ + {"attribute_value": attr_value, "abbr": attr_value} + for attr_value in attr.get("values") + ], + } + ).insert() + attribute.append({"attribute": attr.get("name")}) + + else: + # check for attribute values + item_attr = frappe.get_doc("Item Attribute", attr.get("name")) + if not item_attr.numeric_values: + self._set_new_attribute_values(item_attr, attr.get("values")) + item_attr.save() + attribute.append({"attribute": attr.get("name")}) + + else: + attribute.append( + { + "attribute": attr.get("name"), + "from_range": item_attr.get("from_range"), + "to_range": item_attr.get("to_range"), + "increment": item_attr.get("increment"), + "numeric_values": item_attr.get("numeric_values"), + } + ) + + return attribute + + def _set_new_attribute_values(self, item_attr, values): + for attr_value in values: + if not any( + (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower()) + for d in item_attr.item_attribute_values + ): + item_attr.append("item_attribute_values", {"attribute_value": attr_value, "abbr": attr_value}) + + def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, variant_of=None): + item_dict = { + "variant_of": variant_of, + "is_stock_item": 1, + "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")), + "item_name": product_dict.get("title", "").strip(), + "description": product_dict.get("body_html") or product_dict.get("title"), + "item_group": self._get_item_group(product_dict.get("product_type")), + "has_variants": has_variant, + "attributes": attributes or [], + "stock_uom": product_dict.get("uom") or _("Nos"), + "sku": product_dict.get("sku") or _get_sku(product_dict), + "default_warehouse": warehouse, + "image": _get_item_image(product_dict), + "weight_uom": WEIGHT_TO_ERPNEXT_UOM_MAP[product_dict.get("weight_unit")], + "weight_per_unit": product_dict.get("weight"), + "default_supplier": self._get_supplier(product_dict), + } + + integration_item_code = product_dict["id"] # shopify product_id + variant_id = product_dict.get("variant_id", "") # shopify variant_id if has variants + sku = item_dict["sku"] + + if not _match_sku_and_link_item( + item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant + ): + ecommerce_item.create_ecommerce_item( + MODULE_NAME, + integration_item_code, + item_dict, + variant_id=variant_id, + sku=sku, + variant_of=variant_of, + has_variants=has_variant, + ) + + def _create_item_variants(self, product_dict, warehouse, attributes): + template_item = ecommerce_item.get_erpnext_item( + MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1 + ) + + if template_item: + for variant in product_dict.get("variants"): + shopify_item_variant = { + "id": product_dict.get("id"), + "variant_id": variant.get("id"), + "item_code": variant.get("id"), + "title": product_dict.get("title", "").strip() + "-" + variant.get("title"), + "product_type": product_dict.get("product_type"), + "sku": variant.get("sku"), + "uom": template_item.stock_uom or _("Nos"), + "item_price": variant.get("price"), + "weight_unit": variant.get("weight_unit"), + "weight": variant.get("weight"), + } + + for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): + if variant.get(variant_attr): + attributes[i].update( + { + "attribute_value": self._get_attribute_value( + variant.get(variant_attr), attributes[i] + ) + } + ) + self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) + + def _get_attribute_value(self, variant_attr_val, attribute): + attribute_value = frappe.db.sql( + """select attribute_value from `tabItem Attribute Value` + where parent = %s and (abbr = %s or attribute_value = %s)""", + (attribute["attribute"], variant_attr_val, variant_attr_val), + as_list=1, + ) + return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val) + + def _get_item_group(self, product_type=None): + parent_item_group = get_root_of("Item Group") + + if not product_type: + return parent_item_group + + if frappe.db.get_value("Item Group", product_type, "name"): + return product_type + item_group = frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": product_type, + "parent_item_group": parent_item_group, + "is_group": "No", + } + ).insert() + return item_group.name + + def _get_supplier(self, product_dict): + if product_dict.get("vendor"): + supplier = frappe.db.sql( + f"""select name from tabSupplier + where name = %s or {SUPPLIER_ID_FIELD} = %s """, + (product_dict.get("vendor"), product_dict.get("vendor").lower()), + as_list=1, + ) + + if supplier: + return product_dict.get("vendor") + supplier = frappe.get_doc( + { + "doctype": "Supplier", + "supplier_name": product_dict.get("vendor"), + SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(), + "supplier_group": self._get_supplier_group(), + } + ).insert() + return supplier.name + else: + return "" + + def _get_supplier_group(self): + supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier")) + if not supplier_group: + supplier_group = frappe.get_doc( + {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")} + ).insert() + return supplier_group.name + return supplier_group + + +def _add_weight_details(product_dict): + variants = product_dict.get("variants") + if variants: + product_dict["weight"] = variants[0]["weight"] + product_dict["weight_unit"] = variants[0]["weight_unit"] + + +def _has_variants(product_dict) -> bool: + options = product_dict.get("options") + return bool(options and "Default Title" not in options[0]["values"]) + + +def _get_sku(product_dict): + if product_dict.get("variants"): + return product_dict.get("variants")[0].get("sku") + return "" + + +def _get_item_image(product_dict): + if product_dict.get("image"): + return product_dict.get("image").get("src") + return None + + +def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool: + """Tries to match new item with existing item using Shopify SKU == item_code or product_id. + + Returns true if matched and linked. + """ + sku = item_dict["sku"] + if variant_of or has_variant: + return False + + # Try matching by SKU first + if sku: + item_name = frappe.db.get_value("Item", {"item_code": sku}) + if item_name: + try: + ecommerce_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_name, + "integration_item_code": product_id, + "has_variants": 0, + "variant_id": cstr(variant_id), + "sku": sku, + } + ) + ecommerce_item.insert() + return True + except Exception: + pass + + # Also try matching by product_id as item_code + item_name = frappe.db.get_value("Item", {"item_code": product_id}) + if item_name: + try: + ecommerce_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_name, + "integration_item_code": product_id, + "has_variants": 0, + "variant_id": cstr(variant_id), + "sku": sku or "", + } + ) + ecommerce_item.insert() + return True + except Exception: + return False + + return False + + +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" + for item in order.get("line_items", []): + product_id = item.get("product_id") + variant_id = item.get("variant_id") + sku = item.get("sku") + + # Skip items with null product_id - mapped to fallback items + if not product_id: + continue + + try: + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + if not product.is_synced(): + product.sync_product() + except frappe.DuplicateEntryError: + frappe.logger().info(f"Item {product_id} already exists, skipping") + continue + except Exception as e: + frappe.logger().error(f"Error syncing item {product_id}: {str(e)}") + if "IntegrityError" not in str(e): + raise + continue + + +def get_item_code(shopify_item): + """Get item code using shopify_item dict. + + Item should contain both product_id and variant_id.""" + + product_id = shopify_item.get("product_id") + variant_id = shopify_item.get("variant_id") + sku = shopify_item.get("sku") + title = shopify_item.get("title", "") + + # Handle items without product_id (tips, samples, fees) + if not product_id: + fallback_item = get_shopify_fallback_item(title) + + frappe.logger().info( + f"Line item '{title}' has no product_id - mapped to: {fallback_item}" + ) + + if frappe.db.exists("Item", fallback_item): + return fallback_item + else: + frappe.throw( + f"Fallback item '{fallback_item}' not found for '{title}'" + ) + + # Original logic continues + item = ecommerce_item.get_erpnext_item( + integration=MODULE_NAME, + integration_item_code=product_id, + variant_id=variant_id, + sku=sku, + ) + if item: + return item.item_code + + +@temp_shopify_session +def upload_erpnext_item(doc, method=None): + """This hook is called when inserting new or updating existing `Item`. + + New items are pushed to shopify and changes to existing items are + updated depending on what is configured in "Shopify Setting" doctype. + """ + template_item = item = doc # alias for readability + # a new item recieved from ecommerce_integrations is being inserted + if item.flags.from_integration: + return + + setting = frappe.get_doc(SETTING_DOCTYPE) + + if not setting.is_enabled() or not setting.upload_erpnext_items: + return + + if frappe.flags.in_import: + return + + if item.has_variants: + return + + if len(item.attributes) > 3: + msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) + return + + if doc.variant_of and not setting.upload_variants_as_items: + msgprint(_("Enable variant sync in setting to upload item to Shopify.")) + return + + if item.variant_of: + template_item = frappe.get_doc("Item", item.variant_of) + + product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": template_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + is_new_product = not bool(product_id) + + if is_new_product: + product = Product() + product.published = False + product.status = "active" if setting.sync_new_item_as_active else "draft" + + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + is_successful = product.save() + + if is_successful: + update_default_variant_properties( + product, + sku=template_item.item_code, + price=template_item.get(ITEM_SELLING_RATE_FIELD), + is_stock_item=template_item.is_stock_item, + ) + if item.variant_of: + product.options = [] + product.variants = [] + variant_attributes = { + "title": template_item.item_name, + "sku": item.item_code, + "price": item.get(ITEM_SELLING_RATE_FIELD), + } + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw( + _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ) + product.variants.append(Variant(variant_attributes)) + + product.save() # push variant + + ecom_items = list(set([item, template_item])) + for d in ecom_items: + ecom_item = frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": d.name, + "integration": MODULE_NAME, + "integration_item_code": str(product.id), + "variant_id": "" if d.has_variants else str(product.variants[0].id), + "sku": "" if d.has_variants else str(product.variants[0].sku), + "has_variants": d.has_variants, + "variant_of": d.variant_of, + } + ) + ecom_item.insert() + + write_upload_log(status=is_successful, product=product, item=item) + elif setting.update_shopify_item_on_update: + product = Product.find(product_id) + if product: + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + if not item.variant_of: + update_default_variant_properties( + product, + is_stock_item=template_item.is_stock_item, + price=item.get(ITEM_SELLING_RATE_FIELD), + ) + else: + variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} + product.options = [] + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw( + _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ) + product.variants.append(Variant(variant_attributes)) + + is_successful = product.save() + if is_successful and item.variant_of: + map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) + + write_upload_log(status=is_successful, product=product, item=item, action="Updated") + + +def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): + variant_product_id = frappe.db.get_value( + "Ecommerce Item", + {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME}, + "integration_item_code", + ) + if not variant_product_id: + for variant in shopify_product.variants: + if ( + variant.option1 == variant_attributes.get("option1") + and variant.option2 == variant_attributes.get("option2") + and variant.option3 == variant_attributes.get("option3") + ): + variant_product_id = str(variant.id) + if not frappe.flags.in_test: + frappe.get_doc( + { + "doctype": "Ecommerce Item", + "erpnext_item_code": erpnext_item.name, + "integration": MODULE_NAME, + "integration_item_code": str(shopify_product.id), + "variant_id": variant_product_id, + "sku": str(variant.sku), + "variant_of": erpnext_item.variant_of, + } + ).insert() + break + if not variant_product_id: + msgprint(_("Shopify: Couldn't sync item variant.")) + return variant_product_id + + +def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item): + """Map erpnext fields to shopify, called both when updating and creating new products.""" + + shopify_product.title = erpnext_item.item_name + shopify_product.body_html = erpnext_item.description + shopify_product.product_type = erpnext_item.item_group + + if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values(): + # reverse lookup for key + uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom) + shopify_product.weight = erpnext_item.weight_per_unit + shopify_product.weight_unit = uom + + if erpnext_item.disabled: + shopify_product.status = "draft" + shopify_product.published = False + msgprint(_("Status of linked Shopify product is changed to Draft.")) + + +def get_shopify_weight_uom(erpnext_weight_uom: str) -> str: + for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items(): + if erpnext_uom == erpnext_weight_uom: + return shopify_uom + + +def update_default_variant_properties( + shopify_product: Product, + is_stock_item: bool, + sku: str | None = None, + price: float | None = None, +): + """Shopify creates default variant upon saving the product. + + Some item properties are supposed to be updated on the default variant. + Input: saved shopify_product, sku and price + """ + default_variant: Variant = shopify_product.variants[0] + + # this will create Inventory item and qty will be updated by scheduled job. + if is_stock_item: + default_variant.inventory_management = "shopify" + + if price is not None: + default_variant.price = price + if sku is not None: + default_variant.sku = sku + + +def write_upload_log(status: bool, product: Product, item, action="Created") -> None: + if not status: + msg = _("Failed to upload item to Shopify") + "
" + msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) + msgprint(msg, title="Note", indicator="orange") + + create_shopify_log( + status="Error", + request_data=product.to_dict(), + message=msg, + method="upload_erpnext_item", + ) + else: + create_shopify_log( + status="Success", + request_data=product.to_dict(), + message=f"{action} Item: {item.name}, shopify product: {product.id}", + method="upload_erpnext_item", + )