diff --git a/invoice_az/api.py b/invoice_az/api.py index bf18eca..22758aa 100644 --- a/invoice_az/api.py +++ b/invoice_az/api.py @@ -440,13 +440,11 @@ def get_invoices(token, filters=None): url = "https://new.e-taxes.gov.az/api/po/invoice/public/v2/invoice/find.inbox" - # Base request parameters + # Base request parameters - изменено: только approved и approvedBySystem статусы payload = { "sortBy": "creationDate", "sortAsc": True, - "statuses": ["approved", "onApproval", "updateApproval", "updateRequested", - "cancelRequested", "approvedBySystem", "onApprovalEdited", - "deactivated", "cancelationRefused", "correctionRefused"], + "statuses": ["approved", "approvedBySystem"], # Только утвержденные документы "types": ["current", "corrected"], "kinds": ["defaultInvoice", "agent", "resale", "recycling", "taxCodex163", "taxCodex177_5", "returnInvoice", "returnByAgent", "returnRecycled", @@ -1170,20 +1168,49 @@ def find_or_create_supplier(supplier_name, supplier_tin): @frappe.whitelist() def get_uom_for_unit(unit_name): - """Converts unit name from invoice to system UOM""" - # Conversion dictionary - uom_mapping = { - "шт": "Nos", - "кг": "Kg", - "г": "Gram", - "л": "Litre", - "м": "Meter", - "м2": "Square Meter", - "м3": "Cubic Meter" - } + """Converts unit name from invoice to system UOM with support for mappings""" + if not unit_name: + return "Nos" # Default UOM if none provided - # Return corresponding UOM or "Nos" by default - return uom_mapping.get(unit_name.lower(), "Nos") + # Try to find in settings first + try: + # Get active settings + settings = get_active_settings() + if settings and hasattr(settings, 'unit_mappings'): + # First we need to find corresponding E-Taxes Unit record + etaxes_units = frappe.get_all('E-Taxes Unit', + filters={'etaxes_unit_name': unit_name}, + fields=['name'], + limit=1) + + if etaxes_units: + etaxes_unit_name = etaxes_units[0].name + + # Check if there's a mapping for this unit (case-insensitive) + for mapping in settings.unit_mappings: + if (mapping.etaxes_unit_name == etaxes_unit_name and + mapping.erp_unit): + return mapping.erp_unit + except Exception as e: + frappe.log_error(f"Error getting UOM mapping: {str(e)}", "UOM Mapping Error") + + # If no mapping found, create new E-Taxes Unit record for this unit + # so it can be mapped later + if unit_name and not frappe.db.exists('E-Taxes Unit', {'etaxes_unit_name': unit_name}): + try: + # Generate a consistent code for the unit + unit_code = "UNIT-" + ''.join(e for e in unit_name if e.isalnum()).upper() + + etaxes_unit = frappe.new_doc('E-Taxes Unit') + etaxes_unit.etaxes_unit_name = unit_name + etaxes_unit.etaxes_unit_code = unit_code + etaxes_unit.status = 'New' + etaxes_unit.insert(ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Error creating E-Taxes Unit: {str(e)}", "UOM Mapping Error") + + # If nothing matched, return default UOM + return "Nos" @frappe.whitelist() def load_items_from_invoices(date_from, date_to, max_count=200): @@ -1212,7 +1239,7 @@ def load_items_from_invoices(date_from, date_to, max_count=200): except (ValueError, TypeError): max_count = 200 - # Format filters for the API request + # Format filters for the API request - изменено: только approved и approvedBySystem статусы filters = { "actionOwner": None, "amountFrom": None, @@ -1231,7 +1258,7 @@ def load_items_from_invoices(date_from, date_to, max_count=200): "serialNumber": None, "sortAsc": True, "sortBy": "creationDate", - "statuses": ["approved", "onApproval", "updateApproval", "updateRequested", "cancelRequested", "approvedBySystem", "onApprovalEdited", "deactivated", "cancelationRefused", "correctionRefused"], + "statuses": ["approved", "approvedBySystem"], # Только утвержденные документы "types": ["current", "corrected"] } @@ -1334,7 +1361,7 @@ def load_parties_from_invoices(date_from, date_to, max_count=200, invoice_type=" except (ValueError, TypeError): max_count = 200 - # Format filters for the API request + # Format filters for the API request - изменено: только approved и approvedBySystem статусы filters = { "actionOwner": None, "amountFrom": None, @@ -1353,7 +1380,7 @@ def load_parties_from_invoices(date_from, date_to, max_count=200, invoice_type=" "serialNumber": None, "sortAsc": True, "sortBy": "creationDate", - "statuses": ["approved", "onApproval", "updateApproval", "updateRequested", "cancelRequested", "approvedBySystem", "onApprovalEdited", "deactivated", "cancelationRefused", "correctionRefused"], + "statuses": ["approved", "approvedBySystem"], # Только утвержденные документы "types": ["current", "corrected"] } @@ -1770,29 +1797,7 @@ def match_similar_items(): # Get fresh copy of settings document doc = frappe.get_doc("E-Taxes Settings", settings.name) - - def normalize_string(text, consider_azeri=False): - if not text: - return "" - - # Convert text to lowercase - text = text.lower() - - # If need to consider Azerbaijani characters, perform replacements - if consider_azeri: - # Replace Azerbaijani characters with Latin equivalents for comparison - replacements = { - 'ə': 'e', 'ı': 'i', 'ö': 'o', 'ü': 'u', 'ş': 's', 'ç': 'c', 'ğ': 'g', - 'Ə': 'e', 'I': 'i', 'Ö': 'o', 'Ü': 'u', 'Ş': 's', 'Ç': 'c', 'Ğ': 'g' - } - for az_char, lat_char in replacements.items(): - text = text.replace(az_char, lat_char) - - # Remove all characters except letters and numbers - text = re.sub(r'[^a-z0-9]', '', text) - - return text - + # Process all items for etaxes_item in items_to_process: # Log item being processed @@ -2179,7 +2184,36 @@ def create_unmapped_items(settings_name): "success": False, "message": "An unknown error occurred, please try again in a few minutes." } - + +@frappe.whitelist() +def create_purchase_invoice_from_order(purchase_order_name): + """Создает Purchase Invoice из Purchase Order используя стандартную функцию ERPNext""" + try: + # Импортируем стандартную функцию из модуля Purchase Order + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice + + # Создаем Purchase Invoice из Purchase Order + pi_doc = make_purchase_invoice(purchase_order_name) + + # Устанавливаем даты + pi_doc.posting_date = frappe.utils.nowdate() + pi_doc.due_date = frappe.utils.add_days(frappe.utils.nowdate(), 30) # 30 дней для оплаты + + # ДОБАВЛЕНО: Устанавливаем галочку is_taxes_doc для Purchase Invoice + pi_doc.is_taxes_doc = 1 + + # Сохраняем документ + pi_doc.insert(ignore_permissions=True) + + frappe.log_error(f"Purchase Invoice {pi_doc.name} created from Purchase Order {purchase_order_name}", "Purchase Invoice Creation") + + # Возвращаем имя созданного Purchase Invoice + return pi_doc.name + + except Exception as e: + frappe.log_error(f"Error creating Purchase Invoice from PO {purchase_order_name}: {str(e)}\n{frappe.get_traceback()}", "Purchase Invoice Creation Error") + return None + @frappe.whitelist() def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule_date=None, warehouse=None): """Import invoice taking into account mappings and processing unmapped elements""" @@ -2199,22 +2233,29 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule 'message': 'No active E-Taxes settings found' } - # Create mapping dictionaries + # Create mapping dictionaries with case-insensitive keys item_mappings = {} for mapping in settings.item_mappings: - key = f"{mapping.etaxes_item_name}|{mapping.etaxes_item_code}" + key = f"{mapping.etaxes_item_name.lower()}|{mapping.etaxes_item_code.lower() if mapping.etaxes_item_code else ''}" item_mappings[key] = mapping.erp_item party_mappings = {} for mapping in settings.party_mappings: - key = f"{mapping.etaxes_party_name}|{mapping.etaxes_tax_id}" + key = f"{mapping.etaxes_party_name.lower()}|{mapping.etaxes_tax_id.lower() if mapping.etaxes_tax_id else ''}" party_mappings[key] = (mapping.erp_party, mapping.party_type) + unit_mappings = {} + for mapping in settings.unit_mappings: + if mapping.etaxes_unit_name and mapping.erp_unit: + # Get the unit name from E-Taxes Unit record + etaxes_unit = frappe.get_doc('E-Taxes Unit', mapping.etaxes_unit_name) + unit_mappings[etaxes_unit.etaxes_unit_name.lower()] = mapping.erp_unit + # Get default warehouse default_warehouse = warehouse if not default_warehouse: -# If warehouse not specified, try to get from settings + # If warehouse not specified, try to get from settings if hasattr(settings, 'default_warehouse') and settings.default_warehouse: default_warehouse = settings.default_warehouse else: @@ -2248,7 +2289,7 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule # Set supplier sender = invoice_data.get('sender', {}) - sender_key = f"{sender.get('name', '')}|{sender.get('tin', '')}" + sender_key = f"{sender.get('name', '').lower()}|{sender.get('tin', '').lower()}" if sender_key in party_mappings: po.supplier = party_mappings[sender_key][0] @@ -2305,14 +2346,15 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule # Variable for tracking added items added_items_count = 0 unmatched_items = [] + unmatched_units = [] # Add items from invoice if invoice_data.get("items"): for item in invoice_data.get("items", []): - # Prepare key for mapping search + # Prepare key for mapping search (case-insensitive) item_name = item.get("productName", "") item_code = item.get("itemId", "") - item_key = f"{item_name}|{item_code}" + item_key = f"{item_name.lower()}|{item_code.lower()}" # Look for mapping mapped_item = None @@ -2321,17 +2363,32 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule if item_key in item_mappings: mapped_item = item_mappings[item_key] else: - # Try to find item by code if specified + # Try to find item by code if specified (case-insensitive) if item_code: - item_list = frappe.get_all('Item', filters={'item_code': item_code}, limit=1) + item_list = frappe.get_all('Item', + filters=[['item_code', 'like', f'%{item_code}%']], + limit=1) + if not item_list: + # Try exact match but case-insensitive + item_list = frappe.db.sql(""" + SELECT name FROM tabItem + WHERE LOWER(item_code) = %s + LIMIT 1 + """, (item_code.lower(),), as_dict=True) + if item_list: - mapped_item = item_list[0].name + mapped_item = item_list[0].name if 'name' in item_list[0] else item_list[0]['name'] - # If not found by code, look by name + # If not found by code, look by name (case-insensitive) if not mapped_item: - item_list = frappe.get_all('Item', filters={'item_name': item_name}, limit=1) + item_list = frappe.db.sql(""" + SELECT name FROM tabItem + WHERE LOWER(item_name) = %s + LIMIT 1 + """, (item_name.lower(),), as_dict=True) + if item_list: - mapped_item = item_list[0].name + mapped_item = item_list[0]['name'] # If not found by code or name if not mapped_item: @@ -2352,6 +2409,51 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule }) continue # Move to next element + # Get the UOM from unit mapping or fallback (case-insensitive) + unit_name = item.get("unit", "") + mapped_uom = None + + # Check if there's a mapping for this unit (case-insensitive) + if unit_name.lower() in unit_mappings: + mapped_uom = unit_mappings[unit_name.lower()] + else: + # If no direct mapping, check if UOM exists with exact name (case-insensitive) + uom_list = frappe.db.sql(""" + SELECT name FROM tabUOM + WHERE LOWER(uom_name) = %s + LIMIT 1 + """, (unit_name.lower(),), as_dict=True) + + if uom_list: + mapped_uom = uom_list[0]['name'] + + # Save the unit to E-Taxes Unit if it doesn't exist yet + if unit_name and not frappe.db.exists('E-Taxes Unit', {'etaxes_unit_name': unit_name}): + # Generate a consistent code for the unit + unit_code = "UNIT-" + ''.join(e for e in unit_name if e.isalnum()).upper() + + etaxes_unit = frappe.new_doc('E-Taxes Unit') + etaxes_unit.etaxes_unit_name = unit_name + etaxes_unit.etaxes_unit_code = unit_code + etaxes_unit.source_invoice = invoice_data.get('serialNumber', '') + etaxes_unit.status = 'New' + try: + etaxes_unit.insert(ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Error creating E-Taxes Unit: {str(e)}", "Import Invoice Error") + + # Add to unmapped list if unit is present + if unit_name: + unmatched_units.append({ + 'name': unit_name + }) + + # If no mapping found, use default UOM from item + if not mapped_uom: + # Try to get default UOM from item master + item_doc = frappe.get_doc('Item', mapped_item) + mapped_uom = item_doc.stock_uom + # Add position to PO po_item = frappe.new_doc("Purchase Order Item") po_item.parent = po.name @@ -2364,7 +2466,7 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule po_item.qty = item.get("quantity", 0) po_item.rate = item.get("pricePerUnit", 0) po_item.amount = item.get("cost", 0) - po_item.uom = get_uom_for_unit(item.get("unit", "")) + po_item.uom = mapped_uom # Use the mapped UOM # IMPORTANT: Set schedule_date for each row po_item.schedule_date = date_to_use @@ -2382,6 +2484,14 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule 'unmatched_items': unmatched_items, 'message': f'No mapping found for {len(unmatched_items)} items' } + + # Check if there are unmapped units + if unmatched_units: + return { + 'success': False, + 'unmatched_units': unmatched_units, + 'message': f'No mapping found for {len(unmatched_units)} units' + } # Check that there is at least one element in table if added_items_count == 0: @@ -2407,11 +2517,62 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule # Save again to ensure changes are applied po.save() - return { - 'success': True, - 'message': 'Invoice data imported successfully.', - 'purchase_order': po.name - } + # ИЗМЕНЕНИЕ: Создаем E-Taxes Purchase и устанавливаем связь ДО submit'а + invoice_id = invoice_data.get('id', '') + serial_number = invoice_data.get('serialNumber', '') + sender_name = invoice_data.get('sender', {}).get('name', '') if invoice_data.get('sender') else '' + total = invoice_data.get('totalAmount', 0) or invoice_data.get('amount', 0) + + # Создаем E-Taxes Purchase + etaxes_purchase_result = create_etaxes_purchase(invoice_id, date_to_use, sender_name, total) + if etaxes_purchase_result and etaxes_purchase_result.get('success'): + # Устанавливаем поля E-Taxes ДО submit'а + po.is_taxes_doc = 1 + po.taxes_doc = etaxes_purchase_result.get('name') + # Сохраняем изменения + po.save() + + # ИЗМЕНЕНИЕ: Делаем Submit для Purchase Order + try: + po.submit() + frappe.log_error(f"Purchase Order {po.name} submitted successfully", "Import Invoice Success") + except Exception as e: + frappe.log_error(f"Error submitting Purchase Order {po.name}: {str(e)}", "Submit PO Error") + return { + 'success': False, + 'message': f'Failed to submit Purchase Order: {str(e)}' + } + + # ИЗМЕНЕНИЕ: Создаем Purchase Invoice + try: + pi_name = create_purchase_invoice_from_order(po.name) + if pi_name: + # Делаем Submit для Purchase Invoice + pi = frappe.get_doc("Purchase Invoice", pi_name) + pi.submit() + frappe.log_error(f"Purchase Invoice {pi_name} created and submitted successfully", "Import Invoice Success") + + return { + 'success': True, + 'message': 'Invoice data imported successfully. Purchase Order and Purchase Invoice created.', + 'purchase_order': po.name, + 'purchase_invoice': pi_name + } + else: + # Если не удалось создать Purchase Invoice, всё равно возвращаем успех с Purchase Order + return { + 'success': True, + 'message': 'Invoice data imported successfully. Purchase Order created, but Purchase Invoice creation failed.', + 'purchase_order': po.name + } + except Exception as e: + frappe.log_error(f"Error creating or submitting Purchase Invoice: {str(e)}", "Submit PI Error") + # Если не удалось создать Purchase Invoice, всё равно возвращаем успех с Purchase Order + return { + 'success': True, + 'message': 'Invoice data imported successfully. Purchase Order created, but Purchase Invoice creation failed.', + 'purchase_order': po.name + } except Exception as e: frappe.log_error(f"Error in import_invoice_with_mapping: {str(e)}\n{frappe.get_traceback()}", "Import Error") @@ -2419,7 +2580,7 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule 'success': False, 'message': "An unknown error occurred, please try again in a few minutes." } - + # Helper functions @frappe.whitelist() @@ -2470,37 +2631,6 @@ def normalize_string(text, consider_azeri=True): return text -@frappe.whitelist() -def get_uom_for_unit(unit_name): - """Getting UOM for unit from E-Taxes""" - if not unit_name: - return 'Nos' # Default - - # Unit mapping - uom_mappings = { - 'ədəd': 'Nos', - 'kq': 'Kg', - 'litr': 'Litre', - 'metr': 'Meter', - 'm': 'Meter', - 'km': 'Km', - 'saat': 'Hour', - 'dəst': 'Set', - 'dəfə': 'Time' - } - - normalized_unit = unit_name.lower().strip() - - if normalized_unit in uom_mappings: - return uom_mappings[normalized_unit] - - # Check if such unit exists in system - if frappe.db.exists('UOM', {'uom_name': unit_name}): - return unit_name - - # If no match found, return Nos by default - return 'Nos' - @frappe.whitelist() def create_unmapped_parties(settings_name): """Creating parties for unmapped elements from E-Taxes settings table""" @@ -2868,4 +2998,531 @@ def on_delete_purchase_order(doc, method): frappe.logger().info(f"Successfully deleted E-Taxes Purchase {doc.taxes_doc}") except Exception as e: frappe.log_error(f"Error deleting E-Taxes Purchase {doc.taxes_doc}: {str(e)}\n{frappe.get_traceback()}", - "Purchase Order Delete Error") \ No newline at end of file + "Purchase Order Delete Error") + +@frappe.whitelist() +def get_unmapped_units(): + """Getting unmapped units from E-Taxes""" + try: + # Get active settings + settings = get_active_settings() + if not settings: + return { + 'success': False, + 'message': 'No active E-Taxes settings found' + } + + # Get all unmapped units from E-Taxes + unmapped_units = [] + + # 1. Get all units with 'New' status + etaxes_units = frappe.get_all('E-Taxes Unit', + filters={'status': 'New'}, + fields=['name', 'etaxes_unit_name', 'etaxes_unit_code']) + + # 2. Check if they are already in settings + existing_mappings = {} + for mapping in settings.unit_mappings: + existing_mappings[mapping.etaxes_unit_name] = mapping.erp_unit + + # 3. Add only those that are not in settings + for unit in etaxes_units: + if unit.name not in existing_mappings: + # Create unit object with default settings + unit_data = { + 'name': unit.name, + 'etaxes_unit_name': unit.etaxes_unit_name, + 'etaxes_unit_code': unit.etaxes_unit_code, + } + + unmapped_units.append(unit_data) + + return { + 'success': True, + 'units': unmapped_units + } + except Exception as e: + return { + 'success': False, + 'message': "An unknown error occurred, please try again in a few minutes." + } + +@frappe.whitelist() +def match_similar_units(): + """Matching units by similar names""" + # Записываем активность + record_etaxes_activity() + + try: + import re + from difflib import SequenceMatcher + + # Get active settings + settings = get_active_settings() + if not settings: + return { + 'success': False, + 'message': 'No active E-Taxes settings found' + } + + # Get similarity threshold from settings + similarity_threshold = float(settings.similarity_threshold) / 100.0 if settings.similarity_threshold else 0.8 + consider_azeri = settings.consider_azeri_chars + + # Get all units with "New" status from E-Taxes + etaxes_units = frappe.get_all('E-Taxes Unit', + filters={'status': 'New'}, + fields=['name', 'etaxes_unit_name', 'etaxes_unit_code']) + + # Get existing mappings + existing_mappings = {} + for mapping in settings.unit_mappings: + existing_mappings[mapping.etaxes_unit_name] = mapping.erp_unit + + # Get units with empty mapping (exist in table but erp_unit is None or empty) + units_with_empty_mapping = [] + for etaxes_id, erp_unit in existing_mappings.items(): + if not erp_unit: # If erp_unit is None or empty string + # Find corresponding E-Taxes unit + for unit in etaxes_units: + if unit.name == etaxes_id: + units_with_empty_mapping.append(unit) + break + + # Filter only those that are not in mappings + unmapped_units = [] + for unit in etaxes_units: + if unit.name not in existing_mappings: + unmapped_units.append(unit) + + # Combine two lists: units without mappings and units with empty mappings + units_to_process = unmapped_units + units_with_empty_mapping + + # Get all UOMs from system + system_units = frappe.get_all('UOM', fields=['name', 'uom_name']) + + matched_count = 0 + total_processed = len(units_to_process) + + # Get fresh copy of settings document + doc = frappe.get_doc("E-Taxes Settings", settings.name) + + # Process all units + for etaxes_unit in units_to_process: + # Find similar unit + best_match = None + best_score = 0 + + # Normalize E-Taxes unit name + etaxes_name = normalize_string(etaxes_unit.etaxes_unit_name, consider_azeri) + + for unit in system_units: + # Normalize system unit name + unit_name = normalize_string(unit.uom_name, consider_azeri) + + # Calculate similarity coefficient + name_score = SequenceMatcher(None, etaxes_name, unit_name).ratio() + + # Check if this is a better match + if name_score > best_score and name_score >= similarity_threshold: + best_score = name_score + best_match = unit + + if best_match: + # IMPORTANT: check if there is already a record for this unit in mappings + existing_row = None + for idx, mapping in enumerate(doc.unit_mappings): + if mapping.etaxes_unit_name == etaxes_unit.name: + existing_row = idx + break + + if existing_row is not None: + # If row already exists, update its erp_unit value + doc.unit_mappings[existing_row].erp_unit = best_match.name + doc.unit_mappings[existing_row].mapping_type = 'Automatic' + else: + # If no row, add a new one + doc.append('unit_mappings', { + 'etaxes_unit_name': etaxes_unit.name, + 'erp_unit': best_match.name, + 'mapping_type': 'Automatic' + }) + + matched_count += 1 + + # Update E-Taxes Unit status only for units that have a match + unit_doc = frappe.get_doc('E-Taxes Unit', etaxes_unit.name) + unit_doc.status = 'Mapped' + unit_doc.mapped_unit = best_match.name + unit_doc.save() + + # Save settings only if matches were found + if matched_count > 0: + doc.save() + + return { + 'success': True, + 'matched_count': matched_count, + 'total_processed': total_processed, + 'message': f'Matched {matched_count} out of {total_processed} units' + } + + except Exception as e: + return { + 'success': False, + 'message': "An unknown error occurred, please try again in a few minutes." + } + +@frappe.whitelist() +def create_unmapped_units(settings_name): + """Creating units for unmapped elements from E-Taxes settings table""" + # Записываем активность + record_etaxes_activity() + + try: + import re + + # Get settings + settings_doc = frappe.get_doc("E-Taxes Settings", settings_name) + + # Get elements from table that have no mapping + unmapped_units = [] + + for mapping_unit in settings_doc.unit_mappings: + # Check that unit has no mapping or it's empty + if not mapping_unit.erp_unit: + # Get E-Taxes Unit document for this record + if frappe.db.exists('E-Taxes Unit', mapping_unit.etaxes_unit_name): + etaxes_unit = frappe.get_doc('E-Taxes Unit', mapping_unit.etaxes_unit_name) + unmapped_units.append(etaxes_unit) + + if len(unmapped_units) == 0: + return { + "success": True, + "created_count": 0, + "error_count": 0, + "message": "No unmapped units in table to create" + } + + created_count = 0 + error_count = 0 + + for etaxes_unit in unmapped_units: + try: + # Check if a UOM with this name already exists + uom_exists = frappe.db.exists('UOM', {'uom_name': etaxes_unit.etaxes_unit_name}) + + if uom_exists: + continue + + # Create new UOM + uom_doc = frappe.new_doc("UOM") + + # Standard fields for UOM + uom_doc.uom_name = etaxes_unit.etaxes_unit_name + uom_doc.enabled = 1 + + # Set a default conversion factor if needed + uom_doc.must_be_whole_number = 0 + + # Save UOM + uom_doc.insert() + + # Update mapping in settings + for mapping in settings_doc.unit_mappings: + if mapping.etaxes_unit_name == etaxes_unit.name: + mapping.erp_unit = uom_doc.name + mapping.mapping_type = 'Automatic' + break + + # Update E-Taxes Unit status + etaxes_unit.status = 'Mapped' + etaxes_unit.mapped_unit = uom_doc.name + etaxes_unit.save() + + created_count += 1 + + except Exception as e: + error_count += 1 + + # Save settings after adding all mappings + if created_count > 0: + settings_doc.save() + + return { + "success": True, + "created_count": created_count, + "error_count": error_count, + "message": f"Created {created_count} units, errors: {error_count}" + } + + except Exception as e: + return { + "success": False, + "message": "An unknown error occurred, please try again in a few minutes." + } + + +@frappe.whitelist() +def load_units_from_invoices(date_from, date_to, max_count=200, offset=0): + """Loading units from invoices for a period""" + # Записываем активность + record_etaxes_activity() + + try: + # Counters for tracking + created_count = 0 + skipped_count = 0 + failed_count = 0 + + # Get default settings + asan_login_settings = get_default_asan_login() + + if not asan_login_settings.get('found'): + return { + 'success': False, + 'message': 'No Asan Login settings found' + } + + # Ensure max_count is an integer + try: + max_count = int(max_count) + except (ValueError, TypeError): + max_count = 200 + + # Format filters for the API request - изменено: только approved и approvedBySystem статусы + filters = { + "actionOwner": None, + "amountFrom": None, + "amountTo": None, + "creationDateFrom": date_from, + "creationDateTo": date_to, + "kinds": ["defaultInvoice", "agent", "resale", "recycling", "taxCodex163", "taxCodex177_5", "returnInvoice", "returnByAgent", "returnRecycled", "exportNoteInvoice", "exciseGoodsTransfer", "advanceInvoice"], + "maxCount": max_count, + "offset": offset, + "productCode": None, + "productName": None, + "receiverName": None, + "receiverTin": None, + "senderName": None, + "senderTin": None, + "serialNumber": None, + "sortAsc": True, + "sortBy": "creationDate", + "statuses": ["approved", "approvedBySystem"], # Только утвержденные документы + "types": ["current", "corrected"] + } + + response = get_invoices(asan_login_settings['main_token'], json.dumps(filters)) + + if 'error' in response: + return { + 'success': False, + 'message': response['message'] if 'message' in response else 'Failed to retrieve invoices' + } + + # Extract data from response + items_data = response.get('data', []) or response.get('invoices', []) + + # Check if more data is available + hasMore = False + if 'hasMore' in response: + hasMore = response.get('hasMore') + elif response.get('total', 0) > offset + max_count: + hasMore = True + + # Get details for each invoice and collect unique units + processed_count = 0 + unique_units = {} + + for invoice in items_data: + invoice_id = invoice.get('id', '') + if invoice_id: + # Get detailed information about the invoice + invoice_details = get_invoice_details(asan_login_settings['main_token'], invoice_id) + + # Check for errors + if isinstance(invoice_details, dict) and 'error' in invoice_details: + continue + + serial_number = invoice_details.get('serialNumber', '') + items = invoice_details.get('items', []) + + for item in items: + unit_name = item.get('unit', '') + + # Skip empty units + if not unit_name: + continue + + # Create key for uniqueness check + if unit_name not in unique_units: + # Generate a consistent code for the unit + unit_code = "UNIT-" + ''.join(e for e in unit_name if e.isalnum()).upper() + + unique_units[unit_name] = { + 'etaxes_unit_name': unit_name, + 'etaxes_unit_code': unit_code, + 'source_invoice': serial_number + } + + processed_count += 1 + + # Now create E-Taxes Unit records for each unique unit + for unit_name, unit_data in unique_units.items(): + # Check if such a unit already exists + if frappe.db.exists('E-Taxes Unit', {'etaxes_unit_name': unit_name}): + skipped_count += 1 + continue + + # Create new unit + try: + doc = frappe.get_doc({ + 'doctype': 'E-Taxes Unit', + 'etaxes_unit_name': unit_data['etaxes_unit_name'], + 'etaxes_unit_code': unit_data['etaxes_unit_code'], + 'source_invoice': unit_data['source_invoice'], + 'status': 'New' + }) + doc.insert(ignore_permissions=True) + created_count += 1 + except Exception as e: + failed_count += 1 + + return { + 'success': True, + 'created_count': created_count, + 'skipped_count': skipped_count, + 'failed_count': failed_count, + 'total_invoices': processed_count, + 'unique_units': len(unique_units), + 'hasMore': hasMore + } + + except Exception as e: + frappe.log_error(f"Error in load_units_from_invoices: {str(e)}\n{frappe.get_traceback()}", "Load Units Error") + return { + 'success': False, + 'message': "An unknown error occurred, please try again in a few minutes." + } + +# Обработчик для обновления статусов сопоставленных элементов +def update_mapped_statuses(doc, method=None): + """Update mapped statuses in E-Taxes DocTypes when settings are saved""" + try: + # 1. Handle E-Taxes Unit mappings + if hasattr(doc, 'unit_mappings'): + # Create a dictionary of current mappings + current_unit_mappings = {} + for mapping in doc.unit_mappings: + if mapping.etaxes_unit_name: + current_unit_mappings[mapping.etaxes_unit_name] = mapping.erp_unit + + # Update statuses for all mapped units + for etaxes_unit_name, erp_unit in current_unit_mappings.items(): + if frappe.db.exists('E-Taxes Unit', etaxes_unit_name): + if erp_unit: # If erp_unit has a value, set as Mapped + frappe.db.set_value('E-Taxes Unit', etaxes_unit_name, { + 'status': 'Mapped', + 'mapped_unit': erp_unit + }) + else: # If erp_unit is empty/None, set status back to New + frappe.db.set_value('E-Taxes Unit', etaxes_unit_name, { + 'status': 'New', + 'mapped_unit': None + }) + + # Find units that were previously mapped but are no longer in the mappings + mapped_units = frappe.get_all('E-Taxes Unit', + filters={'status': 'Mapped'}, + fields=['name', 'mapped_unit']) + + for unit in mapped_units: + if unit.name not in current_unit_mappings: + # This unit was mapped before but is no longer in mappings + frappe.db.set_value('E-Taxes Unit', unit.name, { + 'status': 'New', + 'mapped_unit': None + }) + + # 2. Handle E-Taxes Item mappings + if hasattr(doc, 'item_mappings'): + # Create a dictionary of current mappings + current_item_mappings = {} + for mapping in doc.item_mappings: + if mapping.etaxes_item_name: + current_item_mappings[mapping.etaxes_item_name] = mapping.erp_item + + # Update statuses for all mapped items + for etaxes_item_name, erp_item in current_item_mappings.items(): + if frappe.db.exists('E-Taxes Item', etaxes_item_name): + if erp_item: # If erp_item has a value, set as Mapped + frappe.db.set_value('E-Taxes Item', etaxes_item_name, { + 'status': 'Mapped', + 'mapped_item': erp_item + }) + else: # If erp_item is empty/None, set status back to New + frappe.db.set_value('E-Taxes Item', etaxes_item_name, { + 'status': 'New', + 'mapped_item': None + }) + + # Find items that were previously mapped but are no longer in the mappings + mapped_items = frappe.get_all('E-Taxes Item', + filters={'status': 'Mapped'}, + fields=['name', 'mapped_item']) + + for item in mapped_items: + if item.name not in current_item_mappings: + # This item was mapped before but is no longer in mappings + frappe.db.set_value('E-Taxes Item', item.name, { + 'status': 'New', + 'mapped_item': None + }) + + # 3. Handle E-Taxes Parties mappings + if hasattr(doc, 'party_mappings'): + # Create lookup for current party mappings + current_party_mappings = {} + for mapping in doc.party_mappings: + if mapping.etaxes_party_name: + current_party_mappings[mapping.etaxes_party_name] = mapping.erp_party + + # Get all parties with their actual docname + all_parties = frappe.get_all('E-Taxes Parties', + fields=['name', 'etaxes_party_name', 'status', 'mapped_party']) + + # Create a lookup from etaxes_party_name to actual docname + party_name_to_docname = {} + for party in all_parties: + party_name_to_docname[party.etaxes_party_name] = party.name + + # Update statuses for all parties in current mappings + for etaxes_party_name, erp_party in current_party_mappings.items(): + if etaxes_party_name in party_name_to_docname: + docname = party_name_to_docname[etaxes_party_name] + if erp_party: # If mapping exists + frappe.db.set_value('E-Taxes Parties', docname, { + 'status': 'Mapped', + 'mapped_party': erp_party + }) + else: # If mapping was cleared + frappe.db.set_value('E-Taxes Parties', docname, { + 'status': 'New', + 'mapped_party': None + }) + + # Reset status for parties that were mapped but are no longer in mappings + for party in all_parties: + if party.status == 'Mapped' and party.etaxes_party_name not in current_party_mappings: + frappe.db.set_value('E-Taxes Parties', party.name, { + 'status': 'New', + 'mapped_party': None + }) + + # Commit changes + frappe.db.commit() + + except Exception as e: + frappe.log_error(f"Error updating mapped statuses: {str(e)}\n{frappe.get_traceback()}", + "E-Taxes Settings Error") \ No newline at end of file diff --git a/invoice_az/client/e_taxes_unit_list.js b/invoice_az/client/e_taxes_unit_list.js new file mode 100644 index 0000000..94292aa --- /dev/null +++ b/invoice_az/client/e_taxes_unit_list.js @@ -0,0 +1,749 @@ +frappe.listview_settings['E-Taxes Unit'] = { + add_fields: ['status', 'mapped_unit'], + + get_indicator: function(doc) { + if (doc.status === 'Mapped') { + return [__('Mapped'), 'green', 'status,=,Mapped']; + } else if (doc.status === 'Processing') { + return [__('Processing'), 'orange', 'status,=,Processing']; + } else { + return [__('New'), 'red', 'status,=,New']; + } + }, + + onload: function(listview) { + // Add button to load units from E-Taxes + listview.page.add_menu_item(__('Load from E-Taxes'), function() { + // Сначала проверяем актуальность токена без анимации загрузки + check_and_process_token(function() { + show_unit_filter_dialog(); + }); + }); + } +}; + +// Глобальная переменная для хранения диалога загрузки +var loading_dialog = null; + +// Функция для отображения диалога загрузки с анимацией +function show_loading_dialog(title, message, submessage) { + // Если диалог уже открыт, обновляем только сообщение + if (loading_dialog) { + $('#loading_title').text(title); + $('#loading_message').text(message); + if (submessage) { + $('#loading_submessage').text(submessage).show(); + } else { + $('#loading_submessage').hide(); + } + return; + } + + // Создаем диалог с анимацией загрузки + loading_dialog = new frappe.ui.Dialog({ + title: title, + fields: [ + { + fieldname: 'loading_html', + fieldtype: 'HTML', + options: ` +
${message}
+ + +| Type | Name | ID | Position | Status | |
|---|---|---|---|---|---|
| ' + (cert.taxpayerType || '') + ' | ' + + '' + name + ' | ' + + '' + id + ' | ' + + '' + (cert.position || '') + ' | ' + + '' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + ' | ' + + '' + + ' |
This action is irreversible. Are you sure?
'), + function() { + // Дополнительное подтверждение + frappe.confirm( + __('Are you really sure you want to create new UOM units? This cannot be undone.'), + function() { + frappe.call({ + method: 'invoice_az.api.create_unmapped_units', + args: { + 'settings_name': frm.doc.name + }, + callback: function(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Created {0} new UOM units', [r.message.created_count]), + indicator: 'green' + }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Error creating UOM units') + }); + } + } + }); + } + ); + } + ); + } + }, __('Units')); + + // Кнопка для добавления несопоставленных единиц измерения в таблицу + frm.add_custom_button(__('Add unmapped units'), function() { + if(frm.is_dirty()) { + frappe.confirm( + __('Document contains unsaved changes. Save before performing the operation?'), + function() { + // Если "Да", сохраняем и выполняем действие + frm.save().then(function() { + add_unmapped_units(); + }); + }, + function() { + // Если "Нет", не делаем ничего + frappe.show_alert({ + message: __('Operation canceled. Please save the document first.'), + indicator: 'red' + }, 5); + } + ); + } else { + // Если документ не изменен, просто выполняем действие + add_unmapped_units(); + } + + function add_unmapped_units() { + frappe.call({ + method: 'invoice_az.api.get_unmapped_units', + callback: function(r) { + if (r.message && r.message.success) { + if (r.message.units && r.message.units.length > 0) { + // Добавляем несопоставленные единицы в таблицу + r.message.units.forEach(function(unit) { + let row = frm.add_child('unit_mappings'); + + // Используем ID документа (name) вместо отображаемого имени + row.etaxes_unit_name = unit.name; // Должен быть ID документа + + // Тип сопоставления + row.mapping_type = 'Manual'; + }); + frm.refresh_field('unit_mappings'); + frappe.show_alert({ + message: __('Added {0} unmapped units', [r.message.units.length]), + indicator: 'green' + }, 5); + } else { + frappe.show_alert({ + message: __('No unmapped units'), + indicator: 'blue' + }, 5); + } + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Error retrieving units') + }); + } + } + }); + } + }, __('Units')); + } }); @@ -540,4 +753,4 @@ function bulk_edit_table(frm, table_field) { }); d.show(); -} +} \ No newline at end of file diff --git a/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.json b/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.json index 6c29306..afe430b 100644 --- a/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.json +++ b/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.json @@ -25,7 +25,9 @@ "item_mappings_section", "item_mappings", "party_mappings_section", - "party_mappings" + "party_mappings", + "unit_mappings_section", + "unit_mappings" ], "fields": [ { @@ -147,11 +149,22 @@ "fieldtype": "Table", "label": "Business Partner Mappings", "options": "E-Taxes Party Mapping" + }, + { + "fieldname": "unit_mappings_section", + "fieldtype": "Section Break", + "label": "Unit of Measure Mappings" + }, + { + "fieldname": "unit_mappings", + "fieldtype": "Table", + "label": "Unit of Measure Mappings", + "options": "E-Taxes Unit Mapping" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-05-06 12:00:00.000000", + "modified": "2025-05-21 15:00:00.000000", "modified_by": "Administrator", "module": "Invoice Az", "name": "E-Taxes Settings", diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.js b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.js new file mode 100644 index 0000000..021acc2 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Jey ERP and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("E-Taxes Unit", { +// refresh(frm) { + +// }, +// }); diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.json b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.json new file mode 100644 index 0000000..0bb4e3b --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.json @@ -0,0 +1,95 @@ +{ + "actions": [], + "autoname": "field:etaxes_unit_code", + "creation": "2025-05-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "etaxes_unit_name", + "etaxes_unit_code", + "source_invoice", + "status", + "mapped_unit" + ], + "fields": [ + { + "fieldname": "etaxes_unit_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Unit Name", + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "etaxes_unit_code", + "fieldtype": "Data", + "label": "Unit Code", + "read_only": 1, + "unique": 1 + }, + { + "description": "Source invoice number", + "fieldname": "source_invoice", + "fieldtype": "Data", + "label": "Source", + "read_only": 1 + }, + { + "default": "New", + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "New\nMapped\nProcessing", + "read_only": 1 + }, + { + "fieldname": "mapped_unit", + "fieldtype": "Link", + "label": "Mapped UOM", + "options": "UOM", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-05-21 14:25:13.578790", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Unit", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "search_fields": "etaxes_unit_name", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "etaxes_unit_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.py b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.py new file mode 100644 index 0000000..9d49939 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit/e_taxes_unit.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ETaxesUnit(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit/test_e_taxes_unit.py b/invoice_az/invoice_az/doctype/e_taxes_unit/test_e_taxes_unit.py new file mode 100644 index 0000000..e157acc --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit/test_e_taxes_unit.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestETaxesUnit(UnitTestCase): + """ + Unit tests for ETaxesUnit. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestETaxesUnit(IntegrationTestCase): + """ + Integration tests for ETaxesUnit. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.json b/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.json new file mode 100644 index 0000000..7057ce4 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "creation": "2025-05-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "etaxes_unit_name", + "erp_unit", + "mapping_type" + ], + "fields": [ + { + "fieldname": "etaxes_unit_name", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "label": "E-Taxes Unit Name", + "options": "E-Taxes Unit", + "reqd": 1 + }, + { + "fieldname": "erp_unit", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "label": "System UOM", + "options": "UOM" + }, + { + "default": "Manual", + "fieldname": "mapping_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_preview": 1, + "label": "Mapping Type", + "options": "Manual\nAutomatic" + } + ], + "istable": 1, + "links": [], + "modified": "2025-05-21 12:44:49.793210", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Unit Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.py b/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.py new file mode 100644 index 0000000..9496b5e --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_unit_mapping/e_taxes_unit_mapping.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ETaxesUnitMapping(Document): + pass