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/constants.py b/ecommerce_integrations/unicommerce/constants.py index 6e57bcee2..c3a42f13e 100644 --- a/ecommerce_integrations/unicommerce/constants.py +++ b/ecommerce_integrations/unicommerce/constants.py @@ -41,10 +41,10 @@ # Tax -> Unicommerce tax amount field mapping TAX_FIELDS_MAPPING = { - "igst": "integratedGst", - "cgst": "centralGst", - "sgst": "stateGst", - "ugst": "unionTerritoryGst", + "igst": "totalIntegratedGst", + "cgst": "totalCentralGst", + "sgst": "totalStateGst", + "ugst": "totalUnionTerritoryGst", "tcs": "tcsAmount", "cash_on_delivery_charges": "cashOnDeliveryCharges", "gift_wrap_charges": "giftWrapCharges", @@ -52,6 +52,7 @@ "shipping_method_charges": "shippingMethodCharges", } + # Tax -> Unicommerce tax "rate" field mapping TAX_RATE_FIELDS_MAPPING = { "igst": "integratedGstPercentage", @@ -346,4 +347,4 @@ "UL": "Uttarakhand", "JH": "Jharkhand", "JR": "Jharkhand", -} +} \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/customer.py b/ecommerce_integrations/unicommerce/customer.py index 7cac4a309..092e4c147 100644 --- a/ecommerce_integrations/unicommerce/customer.py +++ b/ecommerce_integrations/unicommerce/customer.py @@ -99,6 +99,10 @@ def _create_customer_address(uni_address, address_type, customer, also_shipping= state = uni_address.get("state") if country_code == "IN" and state in UNICOMMERCE_INDIAN_STATES_MAPPING: state = UNICOMMERCE_INDIAN_STATES_MAPPING.get(state) + + email_id=uni_address.get("email") + if not frappe.utils.validate_email_address(email_id, throw=False): + email_id = "" frappe.get_doc( { @@ -109,7 +113,7 @@ def _create_customer_address(uni_address, address_type, customer, also_shipping= "country": country, "county": uni_address.get("district"), "doctype": "Address", - "email_id": uni_address.get("email"), + "email_id": email_id, "phone": uni_address.get("phone"), "pincode": uni_address.get("pincode"), "state": state, @@ -117,4 +121,4 @@ def _create_customer_address(uni_address, address_type, customer, also_shipping= "is_primary_address": int(address_type == "Billing"), "is_shipping_address": int(also_shipping or address_type == "Shipping"), } - ).insert(ignore_mandatory=True) + ).insert(ignore_mandatory=True) \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/delivery_note.py b/ecommerce_integrations/unicommerce/delivery_note.py index 0af339bd0..53e792778 100644 --- a/ecommerce_integrations/unicommerce/delivery_note.py +++ b/ecommerce_integrations/unicommerce/delivery_note.py @@ -7,55 +7,75 @@ @frappe.whitelist() def prepare_delivery_note(): - try: - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) - if not settings.delivery_note: - return - - client = UnicommerceAPIClient() - - days_to_sync = min(settings.get("order_status_days") or 2, 14) - minutes = days_to_sync * 24 * 60 - - # find all Facilities - enabled_facilities = list(settings.get_integration_to_erpnext_wh_mapping().keys()) - enabled_channels = frappe.db.get_list( - "Unicommerce Channel", filters={"enabled": 1}, pluck="channel_id" - ) - - for facility in enabled_facilities: - updated_packages = client.search_shipping_packages(updated_since=minutes, facility_code=facility) - valid_packages = [p for p in updated_packages if p.get("channel") in enabled_channels] - if not valid_packages: - continue - shipped_packages = [p for p in valid_packages if p["status"] in ["DISPATCHED"]] - for order in shipped_packages: - if not frappe.db.exists( - "Delivery Note", {"unicommerce_shipment_id": order["code"]}, "name" - ) and frappe.db.exists("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}): - sales_order = frappe.get_doc("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}) - if frappe.db.exists( + + settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + if not settings.delivery_note: + return + + client = UnicommerceAPIClient() + + days_to_sync = min(settings.get("order_status_days") or 2, 14) + minutes = days_to_sync * 24 * 60 + + # find all Facilities + enabled_facilities = list(settings.get_integration_to_erpnext_wh_mapping().keys()) + enabled_channels = frappe.db.get_list( + "Unicommerce Channel", filters={"enabled": 1}, pluck="channel_id" + ) + + for facility in enabled_facilities: + updated_packages = client.search_shipping_packages(updated_since=minutes, facility_code=facility) + valid_packages = [p for p in updated_packages if p.get("channel") in enabled_channels] + if not valid_packages: + continue + shipped_packages = [p for p in valid_packages if p["status"] in ["DISPATCHED"]] + for order in shipped_packages: + if not frappe.db.exists( + "Delivery Note", {"unicommerce_shipment_id": order["code"]}, "name" + ) and frappe.db.exists("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}): + sales_order = frappe.get_doc("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}) + if frappe.db.exists( + "Sales Invoice", {"unicommerce_order_code": sales_order.unicommerce_order_code} + ): + sales_invoice = frappe.get_doc( "Sales Invoice", {"unicommerce_order_code": sales_order.unicommerce_order_code} - ): - sales_invoice = frappe.get_doc( - "Sales Invoice", {"unicommerce_order_code": sales_order.unicommerce_order_code} - ) + ) + try: create_delivery_note(sales_order, sales_invoice) - except Exception as e: - create_unicommerce_log(status="Error", exception=e, rollback=True) + except Exception as e: + create_unicommerce_log(status="Error", exception=e, rollback=True) + def create_delivery_note(so, sales_invoice): - # Create the delivery note - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note - - res = make_delivery_note(source_name=so.name) - res.unicommerce_order_code = sales_invoice.unicommerce_order_code - res.unicommerce_shipment_id = sales_invoice.unicommerce_shipping_package_code - res.save() - res.submit() - log = create_unicommerce_log(method="create_delevery_note", make_new=True) - frappe.flags.request_id = log.name - create_unicommerce_log(status="Success") - frappe.flags.request_id = None - return res + # Create the delivery note + from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from collections import defaultdict + + res = make_delivery_note(source_name=so.name) + res.unicommerce_order_code = sales_invoice.unicommerce_order_code + res.unicommerce_shipment_id = sales_invoice.unicommerce_shipping_package_code + + si_item_map = defaultdict(list) + for si_item in sales_invoice.items: + si_item_map[si_item.item_code].append(si_item) + + used_si_items = set() + + for item in res.items: + item.against_sales_invoice = sales_invoice.name + for si_item in si_item_map.get(item.item_code, []): + if si_item.name not in used_si_items: + item.si_detail = si_item.name + used_si_items.add(si_item.name) + break + + res.save() + res.submit() + + log = create_unicommerce_log(method="create_delivery_note", make_new=True) + frappe.flags.request_id = log.name + create_unicommerce_log(status="Success") + frappe.flags.request_id = None + + return res \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/doctype/unicommerce_settings/unicommerce_settings.js b/ecommerce_integrations/unicommerce/doctype/unicommerce_settings/unicommerce_settings.js index 20a28a592..9a45b70f8 100644 --- a/ecommerce_integrations/unicommerce/doctype/unicommerce_settings/unicommerce_settings.js +++ b/ecommerce_integrations/unicommerce/doctype/unicommerce_settings/unicommerce_settings.js @@ -34,6 +34,32 @@ frappe.ui.form.on("Unicommerce Settings", { __("Sync Now"), ); }); + + frm.add_custom_button(__("Test sync_new_orders"), () => { + frappe.call({ + method: "ecommerce_integrations.unicommerce.order.sync_new_orders", + args: { force: true }, + freeze: true, + freeze_message: "Running sync_new_orders...", + callback: (r) => { + frappe.msgprint("Done! Check VS Code debugger."); + console.log(r); + }, + }); + }, __("Debug")); + + frm.add_custom_button(__("Test prepare_delivery_note"), () => { + frappe.call({ + method: "ecommerce_integrations.unicommerce.delivery_note.prepare_delivery_note", + args: { force: 1 }, + freeze: true, + freeze_message: "Running prepare_delivery_note...", + callback: (r) => { + frappe.msgprint("Done! Check logs."); + console.log(r); + }, + }); + }, __("Debug")); }, onload: function (frm) { @@ -57,4 +83,4 @@ frappe.ui.form.on("Unicommerce Settings", { }; }; }, -}); +}); \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/inventory.py b/ecommerce_integrations/unicommerce/inventory.py index d8f6961de..64d7a1551 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 \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/invoice.py b/ecommerce_integrations/unicommerce/invoice.py index 3ae3d8f3e..3d9f9576b 100644 --- a/ecommerce_integrations/unicommerce/invoice.py +++ b/ecommerce_integrations/unicommerce/invoice.py @@ -135,6 +135,12 @@ def bulk_generate_invoices( client = UnicommerceAPIClient() frappe.flags.request_id = request_id # for auto-picking current log + # Log: invoice sync initiated + create_unicommerce_log( + status="Queued", + message=f"Invoice sync initiated for orders: {', '.join(sales_orders)}", + ) + update_invoicing_status(sales_orders, "Queued") failed_orders = [] @@ -146,7 +152,14 @@ def bulk_generate_invoices( wh_allocation = warehouse_allocation.get(so_code) if warehouse_allocation else None _generate_invoice(client, so, channel_config, warehouse_allocation=wh_allocation) except Exception as e: - create_unicommerce_log(status="Failure", exception=e, rollback=True, make_new=True) + # Log a failure for this order but keep looping + create_unicommerce_log( + status="Failure", + message=f"Invoice sync failed for order {so_code}", + exception=e, + rollback=True, + make_new=True, + ) failed_orders.append(so_code) _log_invoice_generation(sales_orders, failed_orders) @@ -159,19 +172,23 @@ def _log_invoice_generation(sales_orders, failed_orders): percent_success = len(successful_orders) / len(sales_orders) - failure_message = "\n".join( - [ - f"generate invoices: {percent_success:.3%} invoices successful\n", - f"Failred orders = {', '.join(failed_orders)}", - f"Requested orders = {', '.join(sales_orders)}", - ] - ) - update_invoicing_status(failed_orders, "Failed") update_invoicing_status(successful_orders, "Success") status = {0.0: "Failure", 100.0: "Success"}.get(percent_success) or "Partial Success" - create_unicommerce_log(status=status, message=failure_message) + + # Log: final status for this sync batch + if status == "Success": + msg = f"Invoice sync success for all orders: {', '.join(successful_orders)}" + elif status == "Failure": + msg = f"Invoice sync failed for all orders: {', '.join(failed_orders)}" + else: + msg = ( + f"Invoice sync partially successful. " + f"Success: {', '.join(successful_orders)} | Failed: {', '.join(failed_orders)}" + ) + + create_unicommerce_log(status=status, message=msg) def _get_orders_with_missing_invoice(sales_orders): @@ -300,6 +317,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 +369,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 +391,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 +415,41 @@ 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() + si.calculate_taxes_and_totals() + 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 +461,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 +477,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() @@ -613,4 +706,4 @@ def on_cancel(self, method=None): # self.flags.ignore_links = True ignored_doctypes = list(self.get("ignore_linked_doctypes", [])) ignored_doctypes.append("Pick List") - self.ignore_linked_doctypes = ignored_doctypes + self.ignore_linked_doctypes = ignored_doctypes \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/order.py b/ecommerce_integrations/unicommerce/order.py index 7c78608b1..da3b4bfab 100644 --- a/ecommerce_integrations/unicommerce/order.py +++ b/ecommerce_integrations/unicommerce/order.py @@ -32,108 +32,248 @@ UnicommerceOrder = NewType("UnicommerceOrder", dict[str, Any]) - +INVOICE_READY_PACKAGE_STATES = { + "PACKED", + "READY_TO_SHIP", + "DISPATCHED", + "MANIFESTED", + "SHIPPED", + "DELIVERED", +} + +@frappe.whitelist() 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.""" settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) if not settings.is_enabled(): 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"): return if client is None: client = UnicommerceAPIClient() - status = "COMPLETE" if settings.only_sync_completed_orders else None + try: + status_filter = None + new_orders = list(_get_new_orders(client, status=status_filter) or []) + + if not new_orders: + return + + stats = { + "orders_seen": len(new_orders), + "sales_orders_created": 0, + "sales_orders_existing": 0, + "invoice_attempts": 0, + "invoices_created": 0, + "errors": 0, + } + error_snapshots = [] - new_orders = _get_new_orders(client, status=status) + for order in new_orders: + order_code = order.get("code") - if new_orders is None: - return - for order in new_orders: - sales_order = create_order(order, client=client) + try: + sales_order, so_created = create_order(order, client=client) + if so_created: + stats["sales_orders_created"] += 1 + else: + stats["sales_orders_existing"] += 1 - if settings.only_sync_completed_orders: - _create_sales_invoices(order, sales_order, client) + if not sales_order: + continue + effectively_completed = _is_effectively_completed(order) + if settings.only_sync_completed_orders and not effectively_completed: + continue + + if effectively_completed: + stats["invoice_attempts"] += 1 + stats["invoices_created"] += _create_sales_invoices(order, sales_order, client) + + except Exception: + stats["errors"] += 1 + error_snapshots.append({ + "order_code": order_code, + "error": frappe.get_traceback(with_context=False), + }) + continue + + meaningful_activity = ( + stats["sales_orders_created"] + or stats["invoices_created"] + or stats["errors"] + ) + + if meaningful_activity: + create_unicommerce_log( + status="Warning" if stats["errors"] else "Success", + method="sync_new_orders", + message=( + f"Order sync summary: orders_seen={stats['orders_seen']}, " + f"sales_orders_created={stats['sales_orders_created']}, " + f"sales_orders_existing={stats['sales_orders_existing']}, " + f"invoice_attempts={stats['invoice_attempts']}, " + f"invoices_created={stats['invoices_created']}, " + f"errors={stats['errors']}" + ), + request_data={"errors": error_snapshots[:20]} if error_snapshots else None, + ) + + except Exception as e: + create_unicommerce_log( + status="Error", + method="sync_new_orders", + exception=e, + rollback=True, + ) + raise -def _get_new_orders(client: UnicommerceAPIClient, status: str | None) -> Iterator[UnicommerceOrder] | None: - """Search new sales order from unicommerce.""" - updated_since = 24 * 60 # minutes +def _is_effectively_completed(unicommerce_order: UnicommerceOrder) -> bool: + if not unicommerce_order: + return False + + order_status = (unicommerce_order.get("status") or "").upper() + if order_status in {"COMPLETE", "COMPLETED"}: + return True + + 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: + updated_since = 24 * 60 uni_orders = client.search_sales_order(updated_since=updated_since, status=status) + if not uni_orders: + 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: return 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: 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 + try: + full_order = client.get_sales_order(order_code=order_code) + except Exception as e: + create_unicommerce_log( + status="Error", + method="_get_new_orders", + exception=e, + request_data={"order_code": order_code}, + ) + continue + + if full_order: + yield full_order -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.""" +def _create_sales_invoices(unicommerce_order, sales_order, client: UnicommerceAPIClient) -> int: 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 [] + created_count = 0 + + if not shipping_packages: + return created_count + 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. + package_code = package.get("code") invoice_data = client.get_sales_invoice( - shipping_package_code=package["code"], facility_code=facility_code - ) - existing_si = frappe.db.get_value( - "Sales Invoice", {INVOICE_CODE_FIELD: invoice_data["invoice"]["code"]} + 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: + continue + + existing_si = frappe.db.get_value("Sales Invoice", {INVOICE_CODE_FIELD: invoice_code}) if 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_sales_invoice( - invoice_data["invoice"], + invoice, sales_order.name, update_stock=1, so_data=unicommerce_order, warehouse_allocations=warehouse_allocations, ) + created_count += 1 + except Exception as e: - create_unicommerce_log(status="Error", exception=e, rollback=True, request_data=invoice_data) - frappe.flags.request_id = None - else: - create_unicommerce_log(status="Success", 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, + }, + ) + finally: frappe.flags.request_id = None + return created_count + -def create_order(payload: UnicommerceOrder, request_id: str | None = None, client=None) -> None: +def create_order( + payload: UnicommerceOrder, request_id: str | None = None, client=None +) -> tuple[Any | None, bool]: order = payload existing_so = frappe.db.get_value("Sales Order", {ORDER_CODE_FIELD: order["code"]}) if existing_so: - so = frappe.get_doc("Sales Order", existing_so) - return so + return frappe.get_doc("Sales Order", existing_so), False - # 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 @@ -145,28 +285,30 @@ def create_order(payload: UnicommerceOrder, request_id: str | None = None, clien try: _sync_order_items(order, client=client) customer = sync_customer(order) - order = _create_order(order, customer) + sales_order = _create_order(order, customer) except Exception as e: - create_unicommerce_log(status="Error", exception=e, rollback=True) - frappe.flags.request_id = None - else: - create_unicommerce_log(status="Success") + create_unicommerce_log( + status="Error", + method="create_order", + exception=e, + rollback=True, + request_data={"order_code": payload.get("code")}, + ) + raise + finally: frappe.flags.request_id = None - return order - -def _sync_order_items(order: UnicommerceOrder, client: UnicommerceAPIClient) -> set[str]: - """Ensure all items are synced before processing order. + return sales_order, True - If not synced then product sync for specific item is initiated""" +def _sync_order_items(order: UnicommerceOrder, client: UnicommerceAPIClient) -> set[str]: items = {so_item["itemSku"] for so_item in order["saleOrderItems"]} for item in items: if ecommerce_item.is_synced(integration=MODULE_NAME, integration_item_code=item): continue - else: - import_product_from_unicommerce(sku=item, client=client) + import_product_from_unicommerce(sku=item, client=client) + return items @@ -247,10 +389,6 @@ def _get_line_items( def get_taxes(line_items, channel_config) -> list: taxes = [] - # Note: Tax details are NOT available during SO stage. - # Fields are also different hence during SO stage this function won't capture GST. - # Same function is also used in invoice to recompute accurate tax and charges. - # When invoice is created, tax details are added. tax_map = {tax_head: 0.0 for tax_head in TAX_FIELDS_MAPPING.keys()} item_wise_tax_map = {tax_head: {} for tax_head in TAX_FIELDS_MAPPING.keys()} @@ -268,7 +406,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 = [] @@ -300,16 +437,15 @@ def _get_facility_code(line_items) -> str: def update_shipping_info(doc, method=None): - """When package type is changed, update the shipping information on unicommerce.""" - so = doc if not so.has_value_changed(PACKAGE_TYPE_FIELD): return - package_type = so.get(PACKAGE_TYPE_FIELD) + package_type = so.get(PACKAGE_TYPE_FIELD) if not package_type: return + frappe.enqueue(_update_package_info_on_unicommerce, queue="short", so_code=so.name) @@ -328,8 +464,8 @@ def _update_package_info_on_unicommerce(so_code): frappe.throw(frappe._("Shipping package not present on Unicommerce for order {}").format(so.name)) shipping_package_code = shipping_packages[0].get("code") - facility_code = so.get(FACILITY_CODE_FIELD) + response, status = client.update_shipping_package( shipping_package_code=shipping_package_code, facility_code=facility_code, @@ -344,29 +480,13 @@ 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" - } - }, - """ 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 @@ -383,4 +503,4 @@ def _get_warehouse_allocations(sales_order): "batch_no": item.get(ORDER_ITEM_BATCH_NO), } ) - return item_details + return item_details \ No newline at end of file diff --git a/ecommerce_integrations/unicommerce/product.py b/ecommerce_integrations/unicommerce/product.py index 26c62c16f..93e4e9654 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)) \ No newline at end of file