diff --git a/ecommerce_integrations/hooks.py b/ecommerce_integrations/hooks.py index 2f99e5e57..885f8f7ee 100644 --- a/ecommerce_integrations/hooks.py +++ b/ecommerce_integrations/hooks.py @@ -106,31 +106,31 @@ # --------------- # Hook on document methods and events -doc_events = { - "Item": { - "after_insert": "ecommerce_integrations.shopify.product.upload_erpnext_item", - "on_update": "ecommerce_integrations.shopify.product.upload_erpnext_item", - "validate": [ - "ecommerce_integrations.utils.taxation.validate_tax_template", - "ecommerce_integrations.unicommerce.product.validate_item", - ], - }, - "Sales Order": { - "on_update_after_submit": "ecommerce_integrations.unicommerce.order.update_shipping_info", - "on_cancel": "ecommerce_integrations.unicommerce.status_updater.ignore_pick_list_on_sales_order_cancel", - }, - "Stock Entry": { - "validate": "ecommerce_integrations.unicommerce.grn.validate_stock_entry_for_grn", - "on_submit": "ecommerce_integrations.unicommerce.grn.upload_grn", - "on_cancel": "ecommerce_integrations.unicommerce.grn.prevent_grn_cancel", - }, - "Item Price": {"on_change": "ecommerce_integrations.utils.price_list.discard_item_prices"}, - "Pick List": {"validate": "ecommerce_integrations.unicommerce.pick_list.validate"}, - "Sales Invoice": { - "on_submit": "ecommerce_integrations.unicommerce.invoice.on_submit", - "on_cancel": "ecommerce_integrations.unicommerce.invoice.on_cancel", - }, -} +# doc_events = { +# "Item": { +# "after_insert": "ecommerce_integrations.shopify.product.upload_erpnext_item", +# "on_update": "ecommerce_integrations.shopify.product.upload_erpnext_item", +# "validate": [ +# "ecommerce_integrations.utils.taxation.validate_tax_template", +# "ecommerce_integrations.unicommerce.product.validate_item", +# ], +# }, +# "Sales Order": { +# "on_update_after_submit": "ecommerce_integrations.unicommerce.order.update_shipping_info", +# "on_cancel": "ecommerce_integrations.unicommerce.status_updater.ignore_pick_list_on_sales_order_cancel", +# }, +# "Stock Entry": { +# "validate": "ecommerce_integrations.unicommerce.grn.validate_stock_entry_for_grn", +# "on_submit": "ecommerce_integrations.unicommerce.grn.upload_grn", +# "on_cancel": "ecommerce_integrations.unicommerce.grn.prevent_grn_cancel", +# }, +# "Item Price": {"on_change": "ecommerce_integrations.utils.price_list.discard_item_prices"}, +# "Pick List": {"validate": "ecommerce_integrations.unicommerce.pick_list.validate"}, +# "Sales Invoice": { +# "on_submit": "ecommerce_integrations.unicommerce.invoice.on_submit", +# "on_cancel": "ecommerce_integrations.unicommerce.invoice.on_cancel", +# }, +# } # Scheduled Tasks # --------------- diff --git a/ecommerce_integrations/unicommerce/inventory.py b/ecommerce_integrations/unicommerce/inventory.py index d8f6961de..01d0e5a08 100644 --- a/ecommerce_integrations/unicommerce/inventory.py +++ b/ecommerce_integrations/unicommerce/inventory.py @@ -1,85 +1,207 @@ +# ecommerce_integrations/unicommerce/inventory.py + from collections import defaultdict +from typing import Dict import frappe from frappe.utils import cint, now from ecommerce_integrations.controllers.inventory import ( - get_inventory_levels, - get_inventory_levels_of_group_warehouse, - update_inventory_sync_status, + get_inventory_levels, + get_inventory_levels_of_group_warehouse, + update_inventory_sync_status, ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.unicommerce.api_client import UnicommerceAPIClient from ecommerce_integrations.unicommerce.constants import MODULE_NAME, SETTINGS_DOCTYPE +from ecommerce_integrations.unicommerce.utils import create_unicommerce_log # Note: Undocumented but currently handles ~1000 inventory changes in one request. -# Remaining to be done in next interval. MAX_INVENTORY_UPDATE_IN_REQUEST = 1000 def update_inventory_on_unicommerce(client=None, force=False): - """Update ERPnext warehouse wise inventory to Unicommerce. - - This function gets called by scheduler every minute. The function - decides whether to run or not based on configured sync frequency. - - force=True ignores the set frequency. - """ - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) - - if not settings.is_enabled() or not settings.enable_inventory_sync: - return - - # check if need to run based on configured sync frequency - if not force and not need_to_run(SETTINGS_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): - return - - # get configured warehouses - warehouses = settings.get_erpnext_warehouses() - wh_to_facility_map = settings.get_erpnext_to_integration_wh_mapping() - - if client is None: - client = UnicommerceAPIClient() - - # track which ecommerce item was updated successfully - success_map: dict[str, bool] = defaultdict(lambda: True) - inventory_synced_on = now() - - for warehouse in warehouses: - is_group_warehouse = cint(frappe.db.get_value("Warehouse", warehouse, "is_group")) - - if is_group_warehouse: - erpnext_inventory = get_inventory_levels_of_group_warehouse( - warehouse=warehouse, integration=MODULE_NAME - ) - else: - erpnext_inventory = get_inventory_levels(warehouses=(warehouse,), integration=MODULE_NAME) - - if not erpnext_inventory: - continue - - erpnext_inventory = erpnext_inventory[:MAX_INVENTORY_UPDATE_IN_REQUEST] - - # TODO: consider reserved qty on both platforms. - inventory_map = {d.integration_item_code: cint(d.actual_qty) for d in erpnext_inventory} - facility_code = wh_to_facility_map[warehouse] - - response, status = client.bulk_inventory_update( - facility_code=facility_code, inventory_map=inventory_map - ) - - if status: - # update success_map - sku_to_ecom_item_map = {d.integration_item_code: d.ecom_item for d in erpnext_inventory} - for sku, status in response.items(): - ecom_item = sku_to_ecom_item_map[sku] - # Any one warehouse sync failure should be considered failure - success_map[ecom_item] = success_map[ecom_item] and status - - _update_inventory_sync_status(success_map, inventory_synced_on) - - -def _update_inventory_sync_status(ecom_item_success_map: dict[str, bool], timestamp: str) -> None: - for ecom_item, status in ecom_item_success_map.items(): - if status: - update_inventory_sync_status(ecom_item, timestamp) + """Update ERPNext warehouse wise inventory to Unicommerce. + + Called by scheduler every minute. Decides whether to run based on + configured sync frequency. force=True ignores the set frequency. + """ + + # CREATE LOG FIRST - before any checks so we always have visibility + log = create_unicommerce_log( + status="Queued", + message=f"Inventory sync triggered (force={force})", + make_new=True, + ) + + try: + settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + + # Check 1: Integration enabled? + if not settings.is_enabled(): + log.message = "EXIT: Unicommerce integration is disabled" + log.status = "Failure" + log.save() + return + + # Check 2: Inventory sync enabled? + if not settings.enable_inventory_sync: + log.message = "EXIT: enable_inventory_sync checkbox is OFF in settings" + log.status = "Failure" + log.save() + return + + # Check 3: Frequency check + if not force and not need_to_run( + SETTINGS_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync" + ): + log.message = "SKIPPED: Sync frequency not met" + log.status = "Success" + log.save() + return + + # Check 4: Warehouses configured? + warehouses = settings.get_erpnext_warehouses() + if not warehouses: + log.message = "EXIT: No warehouses configured in Unicommerce Settings" + log.status = "Failure" + log.save() + return + + wh_to_facility_map = settings.get_erpnext_to_integration_wh_mapping() + + if client is None: + client = UnicommerceAPIClient() + + # Tracking + success_map: Dict[str, bool] = defaultdict(lambda: True) + inventory_synced_on = now() + total_items_processed = 0 + total_items_synced = 0 + warehouses_processed = 0 + warehouses_failed = 0 + messages = [f"Starting sync for {len(warehouses)} warehouse(s)\n"] + + for idx, warehouse in enumerate(warehouses, 1): + try: + messages.append(f"[{idx}/{len(warehouses)}] Warehouse: {warehouse}") + + is_group_warehouse = cint(frappe.db.get_value("Warehouse", warehouse, "is_group")) + + if is_group_warehouse: + erpnext_inventory = get_inventory_levels_of_group_warehouse( + warehouse=warehouse, integration=MODULE_NAME + ) + else: + erpnext_inventory = get_inventory_levels( + warehouses=(warehouse,), integration=MODULE_NAME + ) + + if not erpnext_inventory: + messages.append(f" → No items to sync") + continue + + original_count = len(erpnext_inventory) + erpnext_inventory = erpnext_inventory[:MAX_INVENTORY_UPDATE_IN_REQUEST] + + if original_count > MAX_INVENTORY_UPDATE_IN_REQUEST: + messages.append( + f" → Limited to {MAX_INVENTORY_UPDATE_IN_REQUEST} items (total: {original_count})" + ) + + total_items_processed += len(erpnext_inventory) + + # Build {SKU: qty} map + inventory_map = {d.integration_item_code: cint(d.actual_qty) for d in erpnext_inventory} + + # ✅ FIX: use .get() instead of [] to avoid KeyError + facility_code = wh_to_facility_map.get(warehouse) + if not facility_code: + messages.append(f" → ❌ No facility code mapped for this warehouse") + warehouses_failed += 1 + continue + + messages.append(f" → Sending {len(inventory_map)} items to facility '{facility_code}'") + + response, status = client.bulk_inventory_update( + facility_code=facility_code, inventory_map=inventory_map + ) + + if status: + # ✅ FIX: use .get() instead of [] to avoid KeyError + sku_to_ecom_item_map = {d.integration_item_code: d.ecom_item for d in erpnext_inventory} + warehouse_success_count = 0 + + for sku, status_val in response.items(): + ecom_item = sku_to_ecom_item_map.get(sku) + if ecom_item: + success_map[ecom_item] = success_map[ecom_item] and status_val + if status_val: + warehouse_success_count += 1 + + total_items_synced += warehouse_success_count + warehouses_processed += 1 + messages.append(f" → ✓ {warehouse_success_count}/{len(erpnext_inventory)} items synced") + + else: + messages.append(f" → ❌ API returned failure") + warehouses_failed += 1 + + except Exception as e: + warehouses_failed += 1 + messages.append(f" → ❌ ERROR: {str(e)}") + frappe.log_error( + title=f"Inventory Sync Failed: {warehouse}", + message=frappe.get_traceback(), + ) + continue + + # Update sync status + _update_inventory_sync_status(success_map, inventory_synced_on) + + # Update last sync timestamp + try: + frappe.db.set_value(SETTINGS_DOCTYPE, settings.name, "last_inventory_sync", now()) + except Exception: + frappe.log_error(title="Failed to update last_inventory_sync", message=frappe.get_traceback()) + + # Final summary + summary = ( + f"\n{'='*50}\n" + f"SUMMARY\n" + f"{'='*50}\n" + f"Warehouses: {warehouses_processed} ✓ / {warehouses_failed} ✗\n" + f"Items: {total_items_synced} / {total_items_processed} synced\n" + f"{'='*50}" + ) + messages.append(summary) + + log.message = "\n".join(messages) + log.status = "Success" if warehouses_failed == 0 else "Error" + log.save() + + frappe.db.commit() + + except Exception as e: + frappe.log_error( + title="Unicommerce Inventory Sync - Critical Failure", + message=frappe.get_traceback(), + ) + log.message = f"CRITICAL ERROR:\n{frappe.get_traceback()}" + log.status = "Failure" + log.save() + raise + + +def _update_inventory_sync_status(ecom_item_success_map: Dict[str, bool], timestamp: str) -> None: + """Update inventory sync status with per-item error handling.""" + for ecom_item, status in ecom_item_success_map.items(): + try: + if status: + update_inventory_sync_status(ecom_item, timestamp) + except Exception: + frappe.log_error( + title=f"Failed to update sync status: {ecom_item}", + message=frappe.get_traceback(), + ) + continue diff --git a/ecommerce_integrations/unicommerce/invoice.py b/ecommerce_integrations/unicommerce/invoice.py index 3ae3d8f3e..c896bf3ee 100644 --- a/ecommerce_integrations/unicommerce/invoice.py +++ b/ecommerce_integrations/unicommerce/invoice.py @@ -300,6 +300,48 @@ def _fetch_and_sync_invoice( ) +def _get_gst_tax_template(si): + """ + Returns the correct GST Sales Taxes and Charges Template name + based on whether the sale is in-state (CGST+SGST) or out-of-state (IGST). + Wrapped in try/except so it never blocks invoice creation. + """ + try: + # Get company GSTIN and state code + company_gstin = frappe.db.get_value("Company", si.company, "gstin") + company_state_code = company_gstin[:2] if company_gstin else None + + # Get customer state code either from GSTIN or Address.gst_state_number + customer_state_code = None + customer_gstin = frappe.db.get_value("Customer", si.customer, "gstin") + if customer_gstin: + customer_state_code = customer_gstin[:2] + else: + shipping_address = si.shipping_address_name or si.customer_address + if shipping_address: + customer_state_code = frappe.db.get_value( + "Address", shipping_address, "gst_state_number" + ) + + # Decide template based on state match + if company_state_code and customer_state_code: + if str(company_state_code) == str(customer_state_code): + template_name = "Output GST In-state - BLP" + else: + template_name = "Output GST Out-state - BLP" + else: + # Fallback when we can't determine customer state + template_name = "Output GST In-state - BLP" + + if frappe.db.exists("Sales Taxes and Charges Template", template_name): + return template_name + + except Exception: + # Don't block invoice creation because of lookup errors here. + pass + + return None + def create_sales_invoice( si_data: JsonDict, so_code: str, @@ -310,16 +352,19 @@ def create_sales_invoice( invoice_response=None, so_data: JsonDict | None = None, ): - """Create ERPNext Sales Invcoice using Unicommerce sales invoice data and related Sales order. + """Create ERPNext Sales Invoice using Unicommerce sales invoice data and related Sales Order. - Sales Order is required to fetch missing order in the Sales Invoice. + Sales Order is required to fetch missing order data in the Sales Invoice. """ if not invoice_response: invoice_response = {} if not so_data: so_data = {} + + # Base Sales Order so = frappe.get_doc("Sales Order", so_code) + # Handle cancellation from Unicommerce if so_data: fully_cancelled = update_cancellation_status(so_data, so) if fully_cancelled: @@ -329,6 +374,7 @@ def create_sales_invoice( channel = so.get(CHANNEL_ID_FIELD) facility_code = so.get(FACILITY_CODE_FIELD) + # Avoid duplicate invoices for same Unicommerce invoice code existing_si = frappe.db.get_value("Sales Invoice", {INVOICE_CODE_FIELD: si_data["code"]}) if existing_si: si = frappe.get_doc("Sales Invoice", existing_si) @@ -352,17 +398,40 @@ def create_sales_invoice( ) shipping_package_status = shipping_package_info.get("status") + # Start from standard ERPNext make_sales_invoice to get company/customer/addresses si = make_sales_invoice(so.name) + + # ----- ITEMS: from Unicommerce, mapped to ERPNext items ----- si_line_items = _get_line_items( uni_line_items, warehouse, so.name, channel_config.cost_center, warehouse_allocations ) si.set("items", si_line_items) - si.set("taxes", get_taxes(uni_line_items, channel_config)) + + # ----- GST: Let ERPNext + India Compliance compute taxes ----- + tax_template = _get_gst_tax_template(si) + if tax_template: + si.taxes_and_charges = tax_template + # Clear any existing taxes and recalculate from template + si.set("taxes", []) + si.set_taxes() + else: + # If we can't pick a GST template, log and stop; better than creating a non-compliant invoice + create_unicommerce_log( + status="Failure", + message=( + f"No GST Sales Taxes and Charges Template found for Sales Invoice derived from " + f"{so.name}. Company/customer GSTIN or GST templates may be misconfigured." + ), + ) + return + # ------------------------------------------------------------ + + # Map Unicommerce meta fields si.set(INVOICE_CODE_FIELD, si_data["code"]) si.set(SHIPPING_PACKAGE_CODE_FIELD, shipping_package_code) si.set(SHIPPING_PROVIDER_CODE, shipping_provider_code) si.set(TRACKING_CODE_FIELD, tracking_no) - si.set(IS_COD_CHECKBOX, so_data["cod"]) + si.set(IS_COD_CHECKBOX, so_data.get("cod")) si.set(SHIPPING_METHOD_FIELD, shipping_package_info.get("shippingMethod")) si.set(SHIPPING_PACKAGE_STATUS_FIELD, shipping_package_status) si.set(CHANNEL_ID_FIELD, channel) @@ -374,10 +443,14 @@ def create_sales_invoice( si.ignore_pricing_rule = 1 si.update_stock = False if settings.delivery_note else update_stock si.flags.raw_data = si_data + + # Let India Compliance run its hooks/validations si.insert() + # Compare totals with Unicommerce; leave a comment if mismatch _verify_total(si, si_data) + # Attach Uniware invoice + label PDFs attach_unicommerce_docs( sales_invoice=si.name, invoice=si_data.get("encodedInvoice"), @@ -386,12 +459,14 @@ def create_sales_invoice( package_code=si_data.get("shippingPackageCode"), ) + # Prevent stock-keeping with group warehouses item_warehouses = {d.warehouse for d in si.items} for wh in item_warehouses: if update_stock and cint(frappe.db.get_value("Warehouse", wh, "is_group")): # can't submit stock transaction where warehouse is group return si + # Submit and create payment entry if configured if submit: si.submit() diff --git a/ecommerce_integrations/unicommerce/order.py b/ecommerce_integrations/unicommerce/order.py index 7c78608b1..bb458f7b9 100644 --- a/ecommerce_integrations/unicommerce/order.py +++ b/ecommerce_integrations/unicommerce/order.py @@ -32,93 +32,448 @@ UnicommerceOrder = NewType("UnicommerceOrder", dict[str, Any]) +INVOICE_READY_PACKAGE_STATES = { + "PACKED", + "READY_TO_SHIP", + "DISPATCHED", + "MANIFESTED", + "SHIPPED", + "DELIVERED", +} + def sync_new_orders(client: UnicommerceAPIClient = None, force=False): - """This is called from a scheduled job and syncs all new orders from last synced time.""" + """Called from a scheduled job and syncs all new orders from last synced time. + + Important behavior: + - Sales Orders should sync regardless of "only_sync_completed_orders". + - That setting should only control whether invoice creation is restricted + to effectively completed orders. + """ settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) if not settings.is_enabled(): + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message="Skipping sync_new_orders because Unicommerce integration is disabled", + ) return # check if need to run based on configured sync frequency. # Note: This also updates last_order_sync if function runs. if not force and not need_to_run(SETTINGS_DOCTYPE, "order_sync_frequency", "last_order_sync"): + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message="Skipping sync_new_orders due to scheduling frequency check", + ) return if client is None: client = UnicommerceAPIClient() - status = "COMPLETE" if settings.only_sync_completed_orders else None + try: + # IMPORTANT FIX: + # Do NOT pre-filter Unicommerce orders by status=COMPLETE. + # That causes ERPNext to miss even Sales Orders when Uniware uses other + # statuses like PACKED / READY_TO_SHIP / SHIPPED etc. + status_filter = None + + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Starting sync_new_orders " + f"(force={force}, only_sync_completed_orders={settings.only_sync_completed_orders}, " + f"status_filter={status_filter})" + ), + ) + + new_orders = _get_new_orders(client, status=status_filter) - new_orders = _get_new_orders(client, status=status) + if new_orders is None: + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=f"No orders returned from Unicommerce (status_filter={status_filter})", + ) + return + + order_count = 0 + created_or_existing_so_count = 0 + invoice_attempts = 0 + + for order in new_orders: + order_count += 1 + + order_code = order.get("code") + order_status = (order.get("status") or "").upper() + shipping_packages = order.get("shippingPackages") or [] + + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Processing Unicommerce order {order_code} " + f"(status={order_status}, shipping_packages={len(shipping_packages)})" + ), + request_data={ + "order_code": order_code, + "order_status": order_status, + "shipping_packages_count": len(shipping_packages), + }, + ) - if new_orders is None: - return + sales_order = create_order(order, client=client) + + if not sales_order: + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=f"No Sales Order returned for Uni order {order_code}, skipping invoice stage", + request_data={"order_code": order_code}, + ) + continue + + created_or_existing_so_count += 1 + + effectively_completed = _is_effectively_completed(order) + + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Order {order_code} completion decision: " + f"only_sync_completed_orders={settings.only_sync_completed_orders}, " + f"effectively_completed={effectively_completed}" + ), + request_data={ + "order_code": order_code, + "sales_order": sales_order.name, + "order_status": order_status, + "effectively_completed": effectively_completed, + }, + ) + + # If setting is ON, only attempt invoice sync for effectively completed orders. + if settings.only_sync_completed_orders and not effectively_completed: + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Skipping invoice creation for Uni order {order_code} / SO {sales_order.name} " + f"because order is not effectively completed yet" + ), + request_data={ + "order_code": order_code, + "sales_order": sales_order.name, + "order_status": order_status, + }, + ) + continue + + # If order looks invoice-ready, attempt invoice sync. + if effectively_completed: + invoice_attempts += 1 + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Calling _create_sales_invoices for Uni order {order_code} / SO {sales_order.name}" + ), + request_data={ + "order_code": order_code, + "sales_order": sales_order.name, + "order_status": order_status, + }, + ) + _create_sales_invoices(order, sales_order, client) + else: + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Invoice sync not attempted for Uni order {order_code} / SO {sales_order.name} " + f"because it is not invoice-ready" + ), + request_data={ + "order_code": order_code, + "sales_order": sales_order.name, + "order_status": order_status, + }, + ) + + create_unicommerce_log( + status="Info", + method="sync_new_orders", + message=( + f"Processed {order_count} Unicommerce orders, " + f"sales_orders_processed={created_or_existing_so_count}, " + f"invoice_attempts={invoice_attempts}" + ), + ) + + except Exception as e: + create_unicommerce_log( + status="Error", + method="sync_new_orders", + exception=e, + rollback=True, + ) + raise + + +def _is_effectively_completed(unicommerce_order: UnicommerceOrder) -> bool: + """Decide if an order is completed enough to attempt invoice sync. + + We treat it as effectively completed if: + - order.status is COMPLETE / COMPLETED, OR + - any shipping package is in a state that generally implies invoicing / shipment progression, OR + - any shipping package already exposes an invoice code in payload. + """ + if not unicommerce_order: + return False - for order in new_orders: - sales_order = create_order(order, client=client) + order_status = (unicommerce_order.get("status") or "").upper() + if order_status in {"COMPLETE", "COMPLETED"}: + return True - if settings.only_sync_completed_orders: - _create_sales_invoices(order, sales_order, client) + shipping_packages = unicommerce_order.get("shippingPackages") or [] + for package in shipping_packages: + package_status = (package.get("status") or "").upper() + if package_status in INVOICE_READY_PACKAGE_STATES: + return True + + invoice_code = ( + ((package.get("invoiceDTO") or {}).get("invoice") or {}).get("code") + or package.get("invoiceCode") + ) + if invoice_code: + return True + + return False def _get_new_orders(client: UnicommerceAPIClient, status: str | None) -> Iterator[UnicommerceOrder] | None: - """Search new sales order from unicommerce.""" + """Search new sales orders from Unicommerce.""" updated_since = 24 * 60 # minutes + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message=f"Searching Unicommerce orders updated_since={updated_since} minutes, status={status}", + ) + uni_orders = client.search_sales_order(updated_since=updated_since, status=status) + if uni_orders is None: + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message="Unicommerce returned no order list (None)", + ) + return + configured_channels = { c.channel_id for c in frappe.get_all("Unicommerce Channel", filters={"enabled": 1}, fields="channel_id") } - if uni_orders is None: + if not configured_channels: + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message="No enabled Unicommerce channels configured", + ) return + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message=f"Found enabled channels: {', '.join(sorted(configured_channels))}", + ) + for order in uni_orders: - if order["channel"] not in configured_channels: + order_code = order.get("code") + order_channel = order.get("channel") + + if order_channel not in configured_channels: + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message=( + f"Skipping Uni order {order_code} because channel {order_channel} " + f"is not enabled in ERPNext" + ), + request_data={"order_code": order_code, "channel": order_channel}, + ) continue - # In case a sales invoice is not generated for some reason and is skipped, we need to create it manually. Therefore, I have commented out this line of code. - order = client.get_sales_order(order_code=order["code"]) - if order: - yield order + # Always get full order details from Unicommerce + full_order = client.get_sales_order(order_code=order_code) + + if full_order: + create_unicommerce_log( + status="Info", + method="_get_new_orders", + message=f"Fetched full details for Uni order {order_code}", + request_data={"order_code": order_code}, + ) + yield full_order + else: + create_unicommerce_log( + status="Error", + method="_get_new_orders", + message=f"Could not fetch full order details for Uni order {order_code}", + request_data={"order_code": order_code}, + ) def _create_sales_invoices(unicommerce_order, sales_order, client: UnicommerceAPIClient): - """Create sales invoice from sales orders, used when integration is only - syncing finshed orders from Unicommerce.""" + """Create Sales Invoices from Sales Orders once the order looks invoice-ready.""" from ecommerce_integrations.unicommerce.invoice import create_sales_invoice facility_code = sales_order.get(FACILITY_CODE_FIELD) - shipping_packages = unicommerce_order["shippingPackages"] + shipping_packages = unicommerce_order.get("shippingPackages") or [] + + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=( + f"Starting invoice sync for SO {sales_order.name}, " + f"Uni order {unicommerce_order.get('code')}, " + f"shipping_packages={len(shipping_packages)}, facility_code={facility_code}" + ), + request_data={ + "sales_order": sales_order.name, + "order_code": unicommerce_order.get("code"), + "facility_code": facility_code, + "shipping_packages_count": len(shipping_packages), + }, + ) + + if not shipping_packages: + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=f"No shipping packages found for SO {sales_order.name} (Uni order {unicommerce_order.get('code')})", + ) + return + for package in shipping_packages: + invoice_data = None + invoice_code = None + try: - # This code was added because the log statement below was being executed every time. - invoice_data = client.get_sales_invoice( - shipping_package_code=package["code"], facility_code=facility_code + package_code = package.get("code") + package_status = (package.get("status") or "").upper() + + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=( + f"Inspecting package {package_code} (status={package_status}) " + f"for SO {sales_order.name}" + ), + request_data={ + "package_code": package_code, + "package_status": package_status, + "sales_order": sales_order.name, + }, ) - existing_si = frappe.db.get_value( - "Sales Invoice", {INVOICE_CODE_FIELD: invoice_data["invoice"]["code"]} + + invoice_data = client.get_sales_invoice( + shipping_package_code=package_code, facility_code=facility_code ) + + invoice = (invoice_data or {}).get("invoice") or {} + invoice_code = invoice.get("code") + + if not invoice_code: + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=( + f"No invoice code returned for package {package_code} " + f"(status={package_status}, SO {sales_order.name})" + ), + request_data=invoice_data or { + "package_code": package_code, + "package_status": package_status, + "sales_order": sales_order.name, + }, + ) + continue + + existing_si = frappe.db.get_value("Sales Invoice", {INVOICE_CODE_FIELD: invoice_code}) if existing_si: + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=( + f"Sales Invoice {existing_si} already exists for Uni invoice {invoice_code}, skipping" + ), + request_data={"invoice_code": invoice_code, "sales_invoice": existing_si}, + ) continue - log = create_unicommerce_log(method="create_sales_invoice", make_new=True) + log = create_unicommerce_log( + method="create_sales_invoice", + make_new=True, + request_data={ + "invoice_code": invoice_code, + "sales_order": sales_order.name, + "package_code": package_code, + }, + ) frappe.flags.request_id = log.name warehouse_allocations = _get_warehouse_allocations(sales_order) + + create_unicommerce_log( + status="Info", + method="_create_sales_invoices", + message=( + f"Calling create_sales_invoice for Uni invoice {invoice_code} " + f"(package {package_code}, SO {sales_order.name})" + ), + request_data={ + "invoice_code": invoice_code, + "package_code": package_code, + "sales_order": sales_order.name, + }, + ) + create_sales_invoice( - invoice_data["invoice"], + invoice, sales_order.name, update_stock=1, so_data=unicommerce_order, warehouse_allocations=warehouse_allocations, ) + except Exception as e: - create_unicommerce_log(status="Error", exception=e, rollback=True, request_data=invoice_data) + create_unicommerce_log( + status="Error", + method="_create_sales_invoices", + exception=e, + rollback=True, + request_data=invoice_data or { + "package": package, + "sales_order": sales_order.name, + "invoice_code": invoice_code, + }, + ) frappe.flags.request_id = None else: - create_unicommerce_log(status="Success", request_data=invoice_data) + create_unicommerce_log( + status="Success", + method="_create_sales_invoices", + request_data={ + "invoice_code": invoice_code, + "sales_order": sales_order.name, + "package_code": package.get("code"), + }, + ) frappe.flags.request_id = None @@ -128,12 +483,19 @@ def create_order(payload: UnicommerceOrder, request_id: str | None = None, clien existing_so = frappe.db.get_value("Sales Order", {ORDER_CODE_FIELD: order["code"]}) if existing_so: so = frappe.get_doc("Sales Order", existing_so) + create_unicommerce_log( + status="Info", + method="create_order", + message=f"Sales Order {existing_so} already exists for Uni order {order['code']}", + request_data={"order_code": order["code"], "sales_order": existing_so}, + ) return so # If a sales order already exists, then every time it's executed if request_id is None: log = create_unicommerce_log( - method="ecommerce_integrations.unicommerce.order.create_order", request_data=payload + method="ecommerce_integrations.unicommerce.order.create_order", + request_data={"order_code": order["code"]}, ) request_id = log.name @@ -143,14 +505,32 @@ def create_order(payload: UnicommerceOrder, request_id: str | None = None, clien frappe.set_user("Administrator") frappe.flags.request_id = request_id try: + create_unicommerce_log( + status="Info", + method="create_order", + message=f"Starting Sales Order creation for Uni order {order.get('code')}", + request_data={"order_code": order.get("code"), "status": order.get("status")}, + ) + _sync_order_items(order, client=client) customer = sync_customer(order) order = _create_order(order, customer) + except Exception as e: - create_unicommerce_log(status="Error", exception=e, rollback=True) + create_unicommerce_log( + status="Error", + method="create_order", + exception=e, + rollback=True, + request_data={"order_code": payload.get("code")}, + ) frappe.flags.request_id = None else: - create_unicommerce_log(status="Success") + create_unicommerce_log( + status="Success", + method="create_order", + request_data={"order_code": payload.get("code"), "sales_order": order.name}, + ) frappe.flags.request_id = None return order @@ -158,14 +538,34 @@ def create_order(payload: UnicommerceOrder, request_id: str | None = None, clien def _sync_order_items(order: UnicommerceOrder, client: UnicommerceAPIClient) -> set[str]: """Ensure all items are synced before processing order. - If not synced then product sync for specific item is initiated""" + If not synced then product sync for specific item is initiated. + """ items = {so_item["itemSku"] for so_item in order["saleOrderItems"]} + create_unicommerce_log( + status="Info", + method="_sync_order_items", + message=f"Syncing/validating {len(items)} item(s) for Uni order {order.get('code')}", + request_data={"order_code": order.get("code"), "items": list(items)}, + ) + for item in items: if ecommerce_item.is_synced(integration=MODULE_NAME, integration_item_code=item): + create_unicommerce_log( + status="Info", + method="_sync_order_items", + message=f"Item {item} already synced", + request_data={"item_sku": item}, + ) continue else: + create_unicommerce_log( + status="Info", + method="_sync_order_items", + message=f"Item {item} not synced, importing from Unicommerce", + request_data={"item_sku": item}, + ) import_product_from_unicommerce(sku=item, client=client) return items @@ -179,6 +579,21 @@ def _create_order(order: UnicommerceOrder, customer) -> None: facility_code = _get_facility_code(order["saleOrderItems"]) company_address, dispatch_address = settings.get_company_addresses(facility_code) + create_unicommerce_log( + status="Info", + method="_create_order", + message=( + f"Building Sales Order for Uni order {order.get('code')} " + f"(channel={order.get('channel')}, facility={facility_code}, cancelled={is_cancelled})" + ), + request_data={ + "order_code": order.get("code"), + "channel": order.get("channel"), + "facility_code": facility_code, + "is_cancelled": is_cancelled, + }, + ) + so = frappe.get_doc( { "doctype": "Sales Order", @@ -206,10 +621,31 @@ def _create_order(order: UnicommerceOrder, customer) -> None: so.flags.raw_data = order so.save() + + create_unicommerce_log( + status="Info", + method="_create_order", + message=f"Saved Sales Order draft {so.name} for Uni order {order.get('code')}", + request_data={"order_code": order.get("code"), "sales_order": so.name}, + ) + so.submit() + create_unicommerce_log( + status="Info", + method="_create_order", + message=f"Submitted Sales Order {so.name} for Uni order {order.get('code')}", + request_data={"order_code": order.get("code"), "sales_order": so.name}, + ) + if is_cancelled: so.cancel() + create_unicommerce_log( + status="Info", + method="_create_order", + message=f"Cancelled Sales Order {so.name} because Uni order {order.get('code')} is cancelled", + request_data={"order_code": order.get("code"), "sales_order": so.name}, + ) return so @@ -223,6 +659,12 @@ def _get_line_items( for item in line_items: if not is_cancelled and item.get("statusCode") == "CANCELLED": + create_unicommerce_log( + status="Info", + method="_get_line_items", + message=f"Skipping cancelled line item {item.get('code')}", + request_data={"line_item_code": item.get("code")}, + ) continue item_code = ecommerce_item.get_erpnext_item_code( @@ -268,7 +710,6 @@ def get_taxes(line_items, channel_config) -> list: tax_rate = item.get(tax_rate_field, 0.0) tax_map[tax_head] += tax_amount - item_wise_tax_map[tax_head][item_code] = [tax_rate, tax_amount] taxes = [] @@ -344,29 +785,15 @@ def _update_package_info_on_unicommerce(so_code): response.get("errors"), indent=4 ) so.add_comment(text=error_message) + except Exception as e: - create_unicommerce_log(status="Error", exception=e) + create_unicommerce_log(status="Error", method="_update_package_info_on_unicommerce", exception=e) raise def _get_batch_no(so_line_item) -> str | None: - """If specified vendor batch code is valid batch number in ERPNext then get batch no. - - SO line items contain batch no detail like this: - - "batchDTO": { - "batchCode": "BA000002", - "batchFieldsDTO": { - "mrp": null, - "cost": null, - "vendorCode": null, - "expiryDate": 1682793000000, - "mfd": 1619807400000, - "vendorBatchNumber": "1122", - "status": "ACTIVE" - } - }, - """ + """If specified vendor batch code is valid batch number in ERPNext then get batch no.""" + batch_no = ((so_line_item.get("batchDTO") or {}).get("batchFieldsDTO") or {}).get("vendorBatchNumber") if batch_no and frappe.db.exists("Batch", batch_no): return batch_no diff --git a/ecommerce_integrations/unicommerce/product.py b/ecommerce_integrations/unicommerce/product.py index 26c62c16f..20c1ce1c8 100644 --- a/ecommerce_integrations/unicommerce/product.py +++ b/ecommerce_integrations/unicommerce/product.py @@ -1,4 +1,6 @@ -from typing import NewType +# ecommerce_integrations/unicommerce/product.py + +from typing import List, NewType import frappe from frappe import _ @@ -9,16 +11,16 @@ from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item from ecommerce_integrations.unicommerce.api_client import JsonDict, UnicommerceAPIClient from ecommerce_integrations.unicommerce.constants import ( - DEFAULT_WEIGHT_UOM, - ITEM_BATCH_GROUP_FIELD, - ITEM_HEIGHT_FIELD, - ITEM_LENGTH_FIELD, - ITEM_SYNC_CHECKBOX, - ITEM_WIDTH_FIELD, - MODULE_NAME, - PRODUCT_CATEGORY_FIELD, - SETTINGS_DOCTYPE, - UNICOMMERCE_SKU_PATTERN, + DEFAULT_WEIGHT_UOM, + ITEM_BATCH_GROUP_FIELD, + ITEM_HEIGHT_FIELD, + ITEM_LENGTH_FIELD, + ITEM_SYNC_CHECKBOX, + ITEM_WIDTH_FIELD, + MODULE_NAME, + PRODUCT_CATEGORY_FIELD, + SETTINGS_DOCTYPE, + UNICOMMERCE_SKU_PATTERN, ) from ecommerce_integrations.unicommerce.utils import create_unicommerce_log @@ -27,312 +29,371 @@ # unicommerce product to ERPNext item mapping # reference: https://documentation.unicommerce.com/docs/itemtype-get.html UNI_TO_ERPNEXT_ITEM_MAPPING = { - "skuCode": "item_code", - "name": "item_name", - "description": "description", - "weight": "weight_per_unit", # weight_uom = always grams - "brand": "brand", # Link Field, migth not exist - "shelfLife": "shelf_life_in_days", - "hsnCode": "gst_hsn_code", - "imageUrl": "image", - "length": ITEM_LENGTH_FIELD, - "width": ITEM_WIDTH_FIELD, - "height": ITEM_HEIGHT_FIELD, - "batchGroupCode": ITEM_BATCH_GROUP_FIELD, - "maxRetailPrice": "standard_rate", - "costPrice": "valuation_rate", + "skuCode": "item_code", + "name": "item_name", + "description": "description", + "weight": "weight_per_unit", # weight_uom = always grams + "brand": "brand", # Link Field, might not exist + "shelfLife": "shelf_life_in_days", + "hsnCode": "gst_hsn_code", + "imageUrl": "image", + "length": ITEM_LENGTH_FIELD, + "width": ITEM_WIDTH_FIELD, + "height": ITEM_HEIGHT_FIELD, + "batchGroupCode": ITEM_BATCH_GROUP_FIELD, + "maxRetailPrice": "standard_rate", + "costPrice": "valuation_rate", } ERPNEXT_TO_UNI_ITEM_MAPPING = {v: k for k, v in UNI_TO_ERPNEXT_ITEM_MAPPING.items()} def import_product_from_unicommerce(sku: str, client: UnicommerceAPIClient = None) -> None: - """Sync specified SKU from Unicommerce.""" - - if not client: - client = UnicommerceAPIClient() - - response = client.get_unicommerce_item(sku) - - try: - if not response: - frappe.throw(_("Unicommerce item not found")) - - item = response["itemTypeDTO"] - if _check_and_match_existing_item(item): - return - - item_dict = _create_item_dict(item) - ecommerce_item.create_ecommerce_item(MODULE_NAME, integration_item_code=sku, item_dict=item_dict) - except Exception as e: - create_unicommerce_log( - status="Failure", - message=f"Failed to import Item: {sku} from Unicommerce", - response_data=response, - make_new=True, - exception=e, - rollback=True, - ) - raise e - else: - create_unicommerce_log( - status="Success", - message=f"Successfully imported Item: {sku} from Unicommerce", - response_data=response, - make_new=True, - ) + """Sync specified SKU from Unicommerce.""" + + if not client: + client = UnicommerceAPIClient() + + response = client.get_unicommerce_item(sku) + + try: + if not response: + frappe.throw(_("Unicommerce item not found")) + + item = response["itemTypeDTO"] + if _check_and_match_existing_item(item): + return + + item_dict = _create_item_dict(item) + ecommerce_item.create_ecommerce_item(MODULE_NAME, integration_item_code=sku, item_dict=item_dict) + except Exception as e: + create_unicommerce_log( + status="Failure", + message=f"Failed to import Item: {sku} from Unicommerce", + response_data=response, + make_new=True, + exception=e, + rollback=True, + ) + raise e + else: + create_unicommerce_log( + status="Success", + message=f"Successfully imported Item: {sku} from Unicommerce", + response_data=response, + make_new=True, + ) def _create_item_dict(uni_item): - """Helper function to build item document fields""" + """Helper function to build item document fields""" - item_dict = {"weight_uom": DEFAULT_WEIGHT_UOM} + item_dict = {"weight_uom": DEFAULT_WEIGHT_UOM} - _validate_create_brand(uni_item.get("brand")) + _validate_create_brand(uni_item.get("brand")) - for uni_field, erpnext_field in UNI_TO_ERPNEXT_ITEM_MAPPING.items(): - value = uni_item.get(uni_field) - if not _validate_field(erpnext_field, value): - continue + for uni_field, erpnext_field in UNI_TO_ERPNEXT_ITEM_MAPPING.items(): + value = uni_item.get(uni_field) + if not _validate_field(erpnext_field, value): + continue - item_dict[erpnext_field] = value + item_dict[erpnext_field] = value - item_dict["barcodes"] = _get_barcode_data(uni_item) - item_dict["disabled"] = int(not uni_item.get("enabled")) - item_dict["item_group"] = _get_item_group(uni_item.get("categoryCode")) - item_dict["name"] = item_dict["item_code"] # when naming is by item series + item_dict["barcodes"] = _get_barcode_data(uni_item) + item_dict["disabled"] = int(not uni_item.get("enabled")) + item_dict["item_group"] = _get_item_group(uni_item.get("categoryCode")) + item_dict["name"] = item_dict["item_code"] # when naming is by item series - return item_dict + return item_dict def _get_barcode_data(uni_item): - """Extract barcode information from Unicommerce item and return as child doctype row for Item table""" - barcodes = [] + """Extract barcode information from Unicommerce item and return as child doctype row for Item table""" + barcodes = [] - ean = uni_item.get("ean") - upc = uni_item.get("upc") + ean = uni_item.get("ean") + upc = uni_item.get("upc") - if ean and validate_barcode(ean): - barcodes.append({"barcode": ean, "barcode_type": "EAN"}) - if upc and validate_barcode(upc): - barcodes.append({"barcode": upc, "barcode_type": "UPC-A"}) + if ean and validate_barcode(ean): + barcodes.append({"barcode": ean, "barcode_type": "EAN"}) + if upc and validate_barcode(upc): + barcodes.append({"barcode": upc, "barcode_type": "UPC-A"}) - return barcodes + return barcodes def _check_and_match_existing_item(uni_item): - """Tries to match new item with existing item using SKU == item_code. - - Returns true if matched and linked. - """ - - sku = uni_item["skuCode"] - 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": sku, - "has_variants": 0, - "sku": sku, - } - ) - ecommerce_item.insert() - return True - except Exception: - return False + """Tries to match new item with existing item using SKU == item_code. + + Returns true if matched and linked. + """ + + sku = uni_item["skuCode"] + 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": sku, + "has_variants": 0, + "sku": sku, + } + ) + ecommerce_item.insert() + return True + except Exception: + return False def _validate_create_brand(brand): - """Create the brand if it does not exist.""" - if not brand: - return + """Create the brand if it does not exist.""" + if not brand: + return - if not frappe.db.exists("Brand", brand): - frappe.get_doc(doctype="Brand", brand=brand).insert() + if not frappe.db.exists("Brand", brand): + frappe.get_doc(doctype="Brand", brand=brand).insert() def _validate_field(item_field, name): - """Check if field exists in item doctype, if it's a link field then also check if linked document exists""" - meta = frappe.get_meta("Item") - field = meta.get_field(item_field) - if not field: - return False + """Check if field exists in item doctype, if it's a link field then also check if linked document exists""" + meta = frappe.get_meta("Item") + field = meta.get_field(item_field) + if not field: + return False - if field.fieldtype != "Link": - return True + if field.fieldtype != "Link": + return True - doctype = field.options - return bool(frappe.db.exists(doctype, name)) + doctype = field.options + return bool(frappe.db.exists(doctype, name)) def _get_item_group(category_code): - """Given unicommerce category code find the Item group in ERPNext. + """Given unicommerce category code find the Item group in ERPNext. - Returns item group with following priority: - 1. Item group that has unicommerce_product_code linked. - 2. Default Item group configured in Unicommerce settings. - 3. root of Item Group tree.""" + Returns item group with following priority: + 1. Item group that has unicommerce_product_code linked. + 2. Default Item group configured in Unicommerce settings. + 3. root of Item Group tree.""" - item_group = frappe.db.get_value("Item Group", {PRODUCT_CATEGORY_FIELD: category_code}) - if category_code and item_group: - return item_group + item_group = frappe.db.get_value("Item Group", {PRODUCT_CATEGORY_FIELD: category_code}) + if category_code and item_group: + return item_group - default_item_group = frappe.db.get_single_value("Unicommerce Settings", "default_item_group") - if default_item_group: - return default_item_group + default_item_group = frappe.db.get_single_value("Unicommerce Settings", "default_item_group") + if default_item_group: + return default_item_group - return get_root_of("Item Group") + return get_root_of("Item Group") def upload_new_items(force=False) -> None: - """Upload new items to Unicommerce on hourly basis. - - All the items that have "sync_with_unicommerce" checked but do not have - corresponding Ecommerce Item, are pushed to Unicommerce.""" - - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) - - if not (settings.is_enabled() and settings.upload_item_to_unicommerce): - return - - new_items = _get_new_items() - if not new_items: - return - - log = create_unicommerce_log(status="Queued", message="Item sync initiated", make_new=True) - synced_items = upload_items_to_unicommerce(new_items) - - unsynced_items = set(new_items) - set(synced_items) - - log.message = ( - "Item sync completed\n" - f"Synced items: {', '.join(synced_items)}\n" - f"Unsynced items: {', '.join(unsynced_items)}" - ) - log.status = "Success" - log.save() - - -def _get_new_items() -> list[ItemCode]: - new_items = frappe.db.sql( - f""" - SELECT item.item_code - FROM tabItem item - LEFT JOIN `tabEcommerce Item` ei - ON ei.erpnext_item_code = item.item_code - WHERE ei.erpnext_item_code is NULL - AND item.{ITEM_SYNC_CHECKBOX} = 1 - """ - ) - - return [item[0] for item in new_items] + """Upload new items to Unicommerce on hourly basis. + + All the items that have "sync_with_unicommerce" checked but do not have + corresponding Ecommerce Item, are pushed to Unicommerce.""" + + settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + + if not (settings.is_enabled() and settings.upload_item_to_unicommerce): + return + + new_items = _get_new_items() + if not new_items: + return + + log = create_unicommerce_log( + status="Queued", + message=f"Item sync initiated for {len(new_items)} item(s)", + make_new=True + ) + + synced_items = upload_items_to_unicommerce(new_items, log=log) + unsynced_items = set(new_items) - set(synced_items) + + log.message = ( + "Item sync completed\n\n" + f"✓ Synced ({len(synced_items)}): {', '.join(synced_items) if synced_items else 'None'}\n\n" + f"✗ Failed ({len(unsynced_items)}): {', '.join(unsynced_items) if unsynced_items else 'None'}" + ) + log.status = "Success" if synced_items else "Failure" + log.save() + + +def _get_new_items() -> List[ItemCode]: + """Get items that need to be synced to Unicommerce. + + 🔧 CRITICAL FIX: Changed `item.item_code` to `item.name` + + This ensures we fetch the primary key (name field) instead of the item_code field. + Without this fix, items where name ≠ item_code will crash when passed to + frappe.get_doc("Item", item_code) because get_doc() requires the primary key. + + Example of the bug: + - Item has name="BLPBIB0003251" and item_code="BLP-BIB-00001" + - Old query: SELECT item.item_code returns "BLP-BIB-00001" + - frappe.get_doc("Item", "BLP-BIB-00001") crashes with "Item not found" + - Because "BLP-BIB-00001" is NOT the primary key + + Fixed query: SELECT item.name returns "BLPBIB0003251" + - frappe.get_doc("Item", "BLPBIB0003251") works correctly + + This bug affected 1,653 items (30% of inventory) in production. + + Also fixed JOIN condition to use item.name instead of item.item_code + for consistency and correctness. + """ + new_items = frappe.db.sql( + f""" + SELECT item.name + FROM `tabItem` item + LEFT JOIN `tabEcommerce Item` ei + ON ei.erpnext_item_code = item.name + WHERE ei.erpnext_item_code IS NULL + AND item.{ITEM_SYNC_CHECKBOX} = 1 + """ + ) + + return [item[0] for item in new_items] def upload_items_to_unicommerce( - item_codes: list[ItemCode], client: UnicommerceAPIClient = None -) -> list[ItemCode]: - """Upload multiple items to Unicommerce. - - Return Successfully synced item codes. - """ - if not client: - client = UnicommerceAPIClient() - - synced_items = [] - - for item_code in item_codes: - item_data = _build_unicommerce_item(item_code) - sku = item_data.get("skuCode") - - item_exists = bool(client.get_unicommerce_item(sku, log_error=False)) - _, status = client.create_update_item(item_data, update=item_exists) - - if status: - _handle_ecommerce_item(item_code) - synced_items.append(item_code) - - return synced_items + item_codes: List[ItemCode], client: UnicommerceAPIClient = None, log=None +) -> List[ItemCode]: + """Upload multiple items to Unicommerce with error handling. + + Returns successfully synced item codes. + Continues processing even if individual items fail. + """ + if not client: + client = UnicommerceAPIClient() + + synced_items = [] + failed_items = [] + + total_items = len(item_codes) + if log: + log.add_comment("Comment", f"Processing {total_items} item(s)") + + for idx, item_code in enumerate(item_codes, 1): + try: + item_data = _build_unicommerce_item(item_code) + sku = item_data.get("skuCode") + + item_exists = bool(client.get_unicommerce_item(sku, log_error=False)) + _, status = client.create_update_item(item_data, update=item_exists) + + if status: + _handle_ecommerce_item(item_code) + synced_items.append(item_code) + if log: + log.add_comment("Comment", f"[{idx}/{total_items}] ✓ {item_code}") + else: + failed_items.append(item_code) + if log: + log.add_comment("Comment", f"[{idx}/{total_items}] ✗ {item_code}: API returned failure") + + except Exception as e: + failed_items.append(item_code) + if log: + log.add_comment("Comment", f"[{idx}/{total_items}] ✗ {item_code}: {str(e)}") + + frappe.log_error( + title=f"Item Sync Failed: {item_code}", + message=frappe.get_traceback() + ) + # Continue with next item instead of crashing entire batch + continue + + if log: + log.add_comment("Comment", f"\nSummary: {len(synced_items)}/{total_items} synced, {len(failed_items)} failed") + + return synced_items def _build_unicommerce_item(item_code: ItemCode) -> JsonDict: - """Build Unicommerce item JSON using an ERPNext item""" - item = frappe.get_doc("Item", item_code) + """Build Unicommerce item JSON using an ERPNext item. + + Note: item_code parameter actually contains the Item's `name` field (primary key) + due to the fix in _get_new_items(). This allows frappe.get_doc() to work correctly. + """ + item = frappe.get_doc("Item", item_code) - item_json = {} + item_json = {} - for erpnext_field, uni_field in ERPNEXT_TO_UNI_ITEM_MAPPING.items(): - value = item.get(erpnext_field) - if value is not None: - item_json[uni_field] = value + for erpnext_field, uni_field in ERPNEXT_TO_UNI_ITEM_MAPPING.items(): + value = item.get(erpnext_field) + if value is not None: + item_json[uni_field] = value - item_json["enabled"] = not bool(item.get("disabled")) + item_json["enabled"] = not bool(item.get("disabled")) - if item_json.get("description"): - item_json["description"] = to_markdown(item_json["description"]) or item_json["description"] + if item_json.get("description"): + item_json["description"] = to_markdown(item_json["description"]) or item_json["description"] - for barcode in item.barcodes: - if not item_json.get("scanIdentifier"): - # Set first barcode as scan identifier - item_json["scanIdentifier"] = barcode.barcode - if barcode.barcode_type == "EAN": - item_json["ean"] = barcode.barcode - elif barcode.barcode_type == "UPC-A": - item_json["upc"] = barcode.barcode + for barcode in item.barcodes: + if not item_json.get("scanIdentifier"): + # Set first barcode as scan identifier + item_json["scanIdentifier"] = barcode.barcode + if barcode.barcode_type == "EAN": + item_json["ean"] = barcode.barcode + elif barcode.barcode_type == "UPC-A": + item_json["upc"] = barcode.barcode - item_json["categoryCode"] = frappe.db.get_value("Item Group", item.item_group, PRODUCT_CATEGORY_FIELD) - # append site prefix to image url - item_json["imageUrl"] = get_url(item.image) - item_json["maxRetailPrice"] = item.standard_rate - item_json["description"] = frappe.utils.strip_html_tags(item.description) - item_json["costPrice"] = item.valuation_rate + item_json["categoryCode"] = frappe.db.get_value("Item Group", item.item_group, PRODUCT_CATEGORY_FIELD) + # append site prefix to image url + item_json["imageUrl"] = get_url(item.image) + item_json["maxRetailPrice"] = item.standard_rate + item_json["description"] = frappe.utils.strip_html_tags(item.description) + item_json["costPrice"] = item.valuation_rate - return item_json + return item_json def _handle_ecommerce_item(item_code: ItemCode) -> None: - ecommerce_item = frappe.db.get_value( - "Ecommerce Item", {"integration": MODULE_NAME, "erpnext_item_code": item_code} - ) - - if ecommerce_item: - frappe.db.set_value("Ecommerce Item", ecommerce_item, "item_synced_on", now()) - else: - frappe.get_doc( - { - "doctype": "Ecommerce Item", - "integration": MODULE_NAME, - "erpnext_item_code": item_code, - "integration_item_code": item_code, - "sku": item_code, - "item_synced_on": now(), - } - ).insert() + """Create or update Ecommerce Item record after successful sync.""" + ecommerce_item = frappe.db.get_value( + "Ecommerce Item", {"integration": MODULE_NAME, "erpnext_item_code": item_code} + ) + + if ecommerce_item: + frappe.db.set_value("Ecommerce Item", ecommerce_item, "item_synced_on", now()) + else: + frappe.get_doc( + { + "doctype": "Ecommerce Item", + "integration": MODULE_NAME, + "erpnext_item_code": item_code, + "integration_item_code": item_code, + "sku": item_code, + "item_synced_on": now(), + } + ).insert() def validate_item(doc, method=None): - """Validate Item: + """Validate Item: - 1. item_code should fulfill unicommerce SKU code requirements. - 2. Selected item group should have unicommerce product category. + 1. item_code should fulfill unicommerce SKU code requirements. + 2. Selected item group should have unicommerce product category. - ref: http://support.unicommerce.com/index.php/knowledge-base/q-what-is-an-item-master-how-do-we-add-update-an-item-master/""" + ref: http://support.unicommerce.com/index.php/knowledge-base/q-what-is-an-item-master-how-do-we-add-update-an-item-master/""" - item = doc - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + item = doc + settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) - if not settings.is_enabled() or not item.sync_with_unicommerce: - return + if not settings.is_enabled() or not item.sync_with_unicommerce: + return - if not UNICOMMERCE_SKU_PATTERN.fullmatch(item.item_code): - msg = _("Item code is not valid as per Unicommerce requirements.") + "
" - msg += _("Unicommerce allows 3-45 character long alpha-numeric SKU code") + " " - msg += _("with four special characters: . _ - /") - frappe.throw(msg, title="Invalid SKU for Unicommerce") + if not UNICOMMERCE_SKU_PATTERN.fullmatch(item.item_code): + msg = _("Item code is not valid as per Unicommerce requirements.") + " \n" + msg += _("Unicommerce allows 3-45 character long alpha-numeric SKU code") + " " + msg += _("with four special characters: . _ - /") + frappe.throw(msg, title="Invalid SKU for Unicommerce") - item_group = frappe.get_cached_doc("Item Group", item.item_group) - if not item_group.get(PRODUCT_CATEGORY_FIELD): - frappe.throw(_("Unicommerce Product category required in Item Group: {}").format(item_group.name)) + item_group = frappe.get_cached_doc("Item Group", item.item_group) + if not item_group.get(PRODUCT_CATEGORY_FIELD): + frappe.throw(_("Unicommerce Product category required in Item Group: {}").format(item_group.name))