diff --git a/invoice_az/api.py b/invoice_az/api.py index 01be139..b6db151 100644 --- a/invoice_az/api.py +++ b/invoice_az/api.py @@ -284,7 +284,136 @@ def get_invoice_details(token, invoice_id): frappe.log_error(f"[GET_INVOICE_DETAILS] Unexpected error for invoice {invoice_id}: {str(e)}\n{frappe.get_traceback()}", "E-Taxes Error") frappe.log_error(f"Error getting invoice details: {str(e)}", "API Error") return {"error": "unknown_error", "message": "An unknown error occurred, please try again in a few minutes."} - + + +# ======= PURCHASE ACTS (alış aktı) ======= +# Acts are purchase documents whose seller is a physical person (FIN, not TIN). +# List: POST .../v1/act/find.sent ; Detail: GET .../v1/act/{id}?sourceSystem=avis + +ACT_FIND_SENT_URL = "https://new.e-taxes.gov.az/api/po/invoice/public/v1/act/find.sent" +ACT_KINDS = [ + "agriculturalProductsAct", "metalProductsAct", "tireProductsForDisposalAct", + "plasticProductsForDisposalAct", "rawhideSupply", "wasteOils", "act", +] + + +@frappe.whitelist() +def get_purchase_acts(token, filters=None): + """Getting list of purchase acts (alış aktı) from e-taxes find.sent.""" + record_etaxes_activity() + + url = ACT_FIND_SENT_URL + payload = { + "sortBy": "creationDate", + "sortAsc": True, + "statuses": ["approved", "approvedAttachment"], + "types": ["current", "corrected"], + "serialNumber": None, + "sellerFin": None, + "sellerName": None, + "productName": None, + "productCode": None, + "kinds": ACT_KINDS, + "creationDateFrom": "01-01-2024 00:00", + "creationDateTo": "31-12-2024 23:59", + "amountFrom": None, + "amountTo": None, + "offset": 0, + "maxCount": 100, + "phoneNumber": None, + } + + if filters: + try: + filters_dict = json.loads(filters) if isinstance(filters, str) else filters + for key, value in filters_dict.items(): + if key in payload and value is not None: + payload[key] = value + except Exception as e: + frappe.log_error(f"[GET_PURCHASE_ACTS] Error parsing filters: {str(e)}", "Filter Parse Error") + return {"error": "unknown_error", "message": "Invalid filters"} + + headers = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "x-authorization": f"Bearer {token}", + } + + try: + response = requests.post(url, data=json.dumps(payload), headers=headers, timeout=60) + + # 401 → refresh token from DB and retry ONCE + if response.status_code == 401: + fresh_login = get_default_asan_login() + if fresh_login.get("found") and fresh_login.get("main_token"): + headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" + response = requests.post(url, data=json.dumps(payload), headers=headers, timeout=60) + if response.status_code == 401: + return {"error": "unauthorized", "message": "Authentication required. Please login again."} + + if response.status_code == 500: + frappe.log_error("[GET_PURCHASE_ACTS] Server error 500", "E-Taxes Server Error") + return {"error": "server_error", "message": "Service temporarily unavailable. Please try again in a few minutes.", "status_code": 500} + + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + code = e.response.status_code if e.response is not None else None + if code == 401: + return {"error": "unauthorized", "message": "Authentication required. Please login again."} + if code == 500: + return {"error": "server_error", "message": "Service temporarily unavailable. Please try again in a few minutes.", "status_code": 500} + frappe.log_error(f"HTTP error in get_purchase_acts: {str(e)}", "E-Taxes HTTP Error") + return {"error": "http_error", "message": "An unknown error occurred, please try again in a few minutes."} + except Exception as e: + frappe.log_error(f"[GET_PURCHASE_ACTS] Unexpected error: {str(e)}\n{frappe.get_traceback()}", "E-Taxes Error") + return {"error": "unknown_error", "message": "An unknown error occurred, please try again in a few minutes."} + + +@frappe.whitelist() +def get_purchase_act_details(token, act_id, source_system="avis"): + """Getting detailed information about a purchase act (includes line items).""" + record_etaxes_activity() + + url = f"https://new.e-taxes.gov.az/api/po/invoice/public/v1/act/{act_id}?sourceSystem={source_system}" + headers = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "x-authorization": f"Bearer {token}", + } + + try: + response = requests.get(url, headers=headers, timeout=60) + + if response.status_code == 401: + fresh_login = get_default_asan_login() + if fresh_login.get("found") and fresh_login.get("main_token"): + headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" + response = requests.get(url, headers=headers, timeout=60) + if response.status_code == 401: + return {"error": "unauthorized", "message": "Authentication required. Please login again."} + + if response.status_code == 500: + frappe.log_error(f"[GET_PURCHASE_ACT_DETAILS] Server error 500 for act {act_id}", "E-Taxes Server Error") + return {"error": "server_error", "message": "Service temporarily unavailable. Please try again in a few minutes.", "status_code": 500} + + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + code = e.response.status_code if e.response is not None else None + if code == 401: + return {"error": "unauthorized", "message": "Authentication required. Please login again."} + if code == 500: + return {"error": "server_error", "message": "Service temporarily unavailable. Please try again in a few minutes.", "status_code": 500} + frappe.log_error(f"HTTP error in get_purchase_act_details: {str(e)}", "E-Taxes HTTP Error") + return {"error": "http_error", "message": "An unknown error occurred, please try again in a few minutes."} + except Exception as e: + frappe.log_error(f"[GET_PURCHASE_ACT_DETAILS] Unexpected error for act {act_id}: {str(e)}\n{frappe.get_traceback()}", "E-Taxes Error") + return {"error": "unknown_error", "message": "An unknown error occurred, please try again in a few minutes."} + + @frappe.whitelist() def get_uom_for_unit(unit_name): """Converts unit name from invoice to system UOM with support for mappings""" @@ -976,6 +1105,13 @@ def create_purchase_invoice_from_order(purchase_order_name): # ДОБАВЛЕНО: Устанавливаем галочку is_taxes_doc для Purchase Invoice pi_doc.is_taxes_doc = 1 + # Carry the e-taxes purchase type/act type from the Purchase Order + # (make_purchase_invoice does not copy these custom fields by default). + if po_doc.get("purchase_type"): + pi_doc.purchase_type = po_doc.purchase_type + if po_doc.get("act_type"): + pi_doc.act_type = po_doc.act_type + # Переносим комментарии и серию/номер из Purchase Order. # remarks (коммент 1) ставим только если он непустой — иначе пусть # ERPNext сам подставит стандартное "Against Supplier Invoice ...". @@ -998,9 +1134,24 @@ def create_purchase_invoice_from_order(purchase_order_name): "add_deduct_tax": tax_row.add_deduct_tax }) + # If this PI is a Purchase Act, act_kind and per-line tax_type become + # mandatory. Seed safe defaults so the draft can be inserted; the act + # import (import_act_with_mapping) overwrites them with real values + # before submitting. + if pi_doc.get("act_type") == "Purchase Act": + # A purchase act is always for physical goods, and there is no + # separate Purchase Receipt step for it — the invoice itself + # receives the stock. + pi_doc.update_stock = 1 + if not pi_doc.get("act_kind"): + pi_doc.act_kind = "Other Product Receipt Act" + for row in pi_doc.items: + if not row.get("tax_type"): + row.tax_type = "Tax Free" + # Сохраняем документ pi_doc.insert(ignore_permissions=True) - + # ДОБАВЛЕНО: Логируем дату после insert frappe.log_error(f"[DATE DEBUG] PI posting_date after insert: {pi_doc.posting_date}", "Date Debug") frappe.log_error(f"[DATE DEBUG] PI due_date after insert: {pi_doc.due_date}", "Date Debug") @@ -1184,6 +1335,10 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule po.title = f"Invoice {invoice_data.get('serialNumber', '')}" po.invoice_serial_number = invoice_data.get("serialNumber") or invoice_data.get("number", "") + # Mark as an e-taxes purchase document of type EQF (e-qaimə-faktura). + po.purchase_type = 1 + po.act_type = "EQF" + # Comments from e-taxes: comment 1 -> general remarks (human-editable), # comment 2 -> dedicated field. Series/number split from serialNumber. po.remarks = invoice_data.get("invoiceComment") or "" @@ -1442,6 +1597,242 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule 'message': "An unknown error occurred, please try again in a few minutes." } + +ACT_KIND_MAP = { + "agriculturalProductsAct": "Agricultural Products Act", + "metalProductsAct": "Metal Scrap Reception Act", + "tireProductsForDisposalAct": "Tire Products For Disposal Act", + "plasticProductsForDisposalAct": "Plastic Products For Disposal Act", + "rawhideSupply": "Rawhide Supply", + "otherProductReceiptAct": "Other Product Receipt Act", +} + + +def _map_act_kind(kind): + """Map an e-taxes act `kind` to the Purchase Invoice `act_kind` option. + + Generic / unknown kinds ("act", "wasteOils", ...) fall back to the + catch-all "Other Product Receipt Act".""" + return ACT_KIND_MAP.get(kind, "Other Product Receipt Act") + + +@frappe.whitelist() +def import_act_with_mapping(act_data, purchase_order_name=None, schedule_date=None, warehouse=None): + """Import a purchase act (alış aktı) as Purchase Order + Purchase Invoice. + + Mirrors import_invoice_with_mapping but: the seller is a physical person + matched by FIN via the (individual) supplier mappings; the documents are + flagged purchase_type=1 / act_type="Purchase Act" with act_kind and per-line + tax_type (5% purchase_tax_amount for Taxable lines).""" + record_etaxes_activity() + + try: + if isinstance(act_data, str): + act_data = json.loads(act_data) + + settings = get_active_settings() + if not settings: + return {'success': False, 'message': 'No active E-Taxes settings found'} + + # Item mappings (etaxes item name -> ERP item) + item_mappings = {} + for mapping in settings.item_mappings: + if mapping.etaxes_item_name and mapping.erp_item: + item_mappings[mapping.etaxes_item_name] = mapping.erp_item + + # Individual supplier mappings keyed by FIN + fin_to_supplier = {} + for mapping in settings.supplier_mappings: + if mapping.get('is_individual') and mapping.get('fin') and mapping.erp_supplier: + fin_to_supplier[mapping.fin.strip().lower()] = mapping.erp_supplier + + # Resolve default warehouse + default_warehouse = warehouse + if not default_warehouse: + if getattr(settings, 'default_warehouse', None): + default_warehouse = settings.default_warehouse + else: + company = frappe.defaults.get_user_default('Company') + if company: + default_warehouse = frappe.db.get_value('Company', company, 'default_warehouse') + if not default_warehouse: + whs = frappe.get_all('Warehouse', filters={'is_group': 0, 'disabled': 0}, fields=['name'], limit=1) + if whs: + default_warehouse = whs[0].name + if not default_warehouse: + return {'success': False, 'message': 'Default warehouse not found. Please specify warehouse in E-Taxes settings.'} + + # Date from createdAt + created_at_raw = act_data.get("createdAt") + try: + invoice_date = frappe.utils.getdate(created_at_raw) if created_at_raw else frappe.utils.today() + except Exception: + invoice_date = frappe.utils.today() + date_to_use = invoice_date or (frappe.utils.getdate(schedule_date) if schedule_date else frappe.utils.today()) + + # Match the seller (individual) by FIN + seller = act_data.get("seller") or {} + seller_fin = (seller.get("fin") or "").strip() + seller_name = (seller.get("fullName") or "").strip() + erp_supplier = fin_to_supplier.get(seller_fin.lower()) if seller_fin else None + if not erp_supplier: + return { + 'success': False, + 'unmatched_suppliers': [{'name': seller_name, 'fin': seller_fin, 'type': 'Individual'}], + 'message': f'No mapping found for individual seller (FIN {seller_fin}): {seller_name}' + } + + # Create Purchase Order + po = frappe.new_doc('Purchase Order') + po.supplier = erp_supplier + po.transaction_date = invoice_date + po.schedule_date = date_to_use + po.company = frappe.defaults.get_user_default('Company') + + po.title = f"Act {act_data.get('serialNumber', '')}" + po.invoice_serial_number = act_data.get("serialNumber") or "" + po.purchase_type = 1 + po.act_type = "Purchase Act" + po.remarks = act_data.get("note") or "" + po.etaxes_invoice_comment_2 = act_data.get("additionalNote") or "" + etaxes_series, etaxes_number = split_serial_number(act_data.get("serialNumber")) + po.etaxes_invoice_series = etaxes_series + po.etaxes_invoice_number = etaxes_number + + # Items (one PO line per act item, preserving order so PI lines align 1:1) + added_items_count = 0 + unmatched_items = [] + unmatched_units = [] + line_tax = [] # (tax_type, purchase_tax_amount) aligned to PO item order + + for item in act_data.get("items", []): + item_name = (item.get("productName") or "").strip() + item_name_truncated = item_name[:140] + + mapped_item = item_mappings.get(item_name_truncated) + if not mapped_item: + il = item_name_truncated.lower() + for k, v in item_mappings.items(): + if k.lower() == il: + mapped_item = v + break + if not mapped_item: + ni = normalize_string(item_name_truncated, consider_azeri=True) + for k, v in item_mappings.items(): + if normalize_string(k, consider_azeri=True) == ni: + mapped_item = v + break + if not mapped_item: + unmatched_items.append({'name': item_name, 'code': item.get('productName', '')}) + continue + + unit_name = item.get("unit", "") + mapped_uom = None + if unit_name: + try: + mapped_uom = frappe.db.get_value('E-Taxes Unit', {'etaxes_unit_name': unit_name}, 'mapped_unit') + except Exception: + pass + if not mapped_uom: + if unit_name: + unmatched_units.append({'name': unit_name}) + try: + mapped_uom = frappe.get_doc('Item', mapped_item).stock_uom + except Exception: + mapped_uom = settings.default_uom if settings.default_uom else "Nos" + + po_item = frappe.new_doc("Purchase Order Item") + po_item.item_code = mapped_item + try: + idoc = frappe.get_doc('Item', mapped_item) + po_item.item_name = idoc.item_name + po_item.description = idoc.description or idoc.item_name + except Exception: + po_item.item_name = item_name + po_item.description = item_name + po_item.qty = item.get("quantity", 0) + po_item.rate = item.get("pricePerUnit", 0) + po_item.amount = item.get("cost", 0) + po_item.uom = mapped_uom + po_item.schedule_date = date_to_use + po_item.warehouse = default_warehouse + po.append("items", po_item) + + tax_rate = (item.get("taxRate") or "").strip().lower() + tax_type = "Tax Free" if tax_rate in ("", "taxfree", "tax_free", "free") else "Taxable" + amount = item.get("cost", 0) or 0 + tax_amount = round(amount * 0.05, 2) if tax_type == "Taxable" else 0 + line_tax.append((tax_type, tax_amount)) + + added_items_count += 1 + + if unmatched_items: + return {'success': False, 'unmatched_items': unmatched_items, + 'message': f'No mapping found for {len(unmatched_items)} items'} + if unmatched_units: + return {'success': False, 'unmatched_units': unmatched_units, + 'message': f'No mapping found for {len(unmatched_units)} units'} + if added_items_count == 0: + return {'success': False, 'message': 'Could not add any items to act'} + + po.save() + + # E-Taxes Purchase tracking + link (before submit) + act_id = act_data.get('id', '') + total = act_data.get('amount', 0) or 0 + etaxes_purchase_result = create_etaxes_purchase( + act_id, date_to_use, seller_name, total, + series=etaxes_series, number=etaxes_number, + comment=act_data.get("note") or "", comment2=act_data.get("additionalNote") or "", + ) + etaxes_purchase_name = None + if etaxes_purchase_result and etaxes_purchase_result.get('success'): + etaxes_purchase_name = etaxes_purchase_result.get('name') + po.is_taxes_doc = 1 + po.taxes_doc = etaxes_purchase_name + po.save() + + # Submit Purchase Order (rollback the whole import on failure) + try: + po.submit() + except Exception as e: + frappe.log_error(f"Error submitting Purchase Order (act) {po.name}: {str(e)}", "Submit PO Act Error") + _rollback_import(po_name=po.name, etaxes_purchase_name=etaxes_purchase_name) + return {'success': False, 'message': f'Failed to submit Purchase Order: {str(e)}'} + + # Create + submit Purchase Invoice + pi_name = None + try: + pi_name = create_purchase_invoice_from_order(po.name) + if not pi_name: + raise Exception("create_purchase_invoice_from_order returned no name") + pi = frappe.get_doc("Purchase Invoice", pi_name) + # Act header (act_type was copied from PO; set the real act_kind) + pi.purchase_type = 1 + pi.act_type = "Purchase Act" + pi.act_kind = _map_act_kind(act_data.get("kind")) + # Per-line tax_type / purchase_tax_amount, aligned 1:1 with act items + for idx, row in enumerate(pi.items): + if idx < len(line_tax): + row.tax_type, row.purchase_tax_amount = line_tax[idx] + pi.save() + pi.submit() + return { + 'success': True, + 'message': 'Act imported successfully. Purchase Order and Purchase Invoice created.', + 'purchase_order': po.name, + 'purchase_invoice': pi_name, + } + except Exception as e: + frappe.log_error(f"Error creating or submitting Purchase Invoice (act): {str(e)}\n{frappe.get_traceback()}", "Submit PI Act Error") + _rollback_import(po_name=po.name, etaxes_purchase_name=etaxes_purchase_name, pi_name=pi_name) + return {'success': False, 'message': f'Failed to submit Purchase Invoice: {str(e)}'} + + except Exception as e: + frappe.log_error(f"Error in import_act_with_mapping: {str(e)}\n{frappe.get_traceback()}", "Import Act Error") + return {'success': False, 'message': "An unknown error occurred, please try again in a few minutes."} + + @frappe.whitelist() def get_etaxes_purchases(): """Gets list of all E-Taxes Purchase records""" @@ -2081,7 +2472,11 @@ def update_mapped_statuses(doc, method=None): fields=['name', 'mapped_customer']) for customer in mapped_customers: - if customer.etaxes_party_name not in current_customer_mappings: + # `name` IS the etaxes_party_name (autoname); get_all only + # fetched name+mapped_customer, so compare on .name — using + # .etaxes_party_name here returns None and wrongly resets + # every Mapped customer back to New. + if customer.name not in current_customer_mappings: frappe.db.set_value('E-Taxes Customers', customer.name, { 'status': 'New', 'mapped_customer': None @@ -2123,7 +2518,11 @@ def update_mapped_statuses(doc, method=None): fields=['name', 'mapped_supplier']) for supplier in mapped_suppliers: - if supplier.etaxes_party_name not in current_supplier_mappings: + # `name` IS the etaxes_party_name (autoname); get_all only + # fetched name+mapped_supplier, so compare on .name — using + # .etaxes_party_name here returns None and wrongly resets + # every Mapped supplier back to New. + if supplier.name not in current_supplier_mappings: frappe.db.set_value('E-Taxes Suppliers', supplier.name, { 'status': 'New', 'mapped_supplier': None @@ -2908,11 +3307,13 @@ def get_unmapped_suppliers(): existing_mappings[mapping.etaxes_supplier_name] = mapping.erp_supplier # Get unmapped suppliers from E-Taxes Suppliers - etaxes_suppliers = frappe.get_all('E-Taxes Suppliers', + etaxes_suppliers = frappe.get_all('E-Taxes Suppliers', filters={'status': 'New'}, - fields=['name', 'etaxes_party_name', 'etaxes_tax_id', - 'etaxes_party_type', 'etaxes_address', 'source_invoice']) - + fields=['name', 'etaxes_party_name', 'etaxes_tax_id', + 'etaxes_party_type', 'etaxes_address', 'source_invoice', + 'is_individual', 'fin', 'passport_serial_number', + 'first_name', 'last_name', 'phone_number', 'date_of_birth']) + # Filter only those that are not in mappings for supplier in etaxes_suppliers: if supplier.etaxes_party_name not in existing_mappings: @@ -2922,6 +3323,14 @@ def get_unmapped_suppliers(): 'etaxes_party_name': supplier.etaxes_party_name, 'etaxes_tax_id': supplier.etaxes_tax_id, 'etaxes_party_type': supplier.etaxes_party_type, + # Individual (purchase-act seller) info + 'is_individual': supplier.is_individual, + 'fin': supplier.fin, + 'passport_serial_number': supplier.passport_serial_number, + 'first_name': supplier.first_name, + 'last_name': supplier.last_name, + 'phone_number': supplier.phone_number, + 'date_of_birth': supplier.date_of_birth, # Common settings 'supplier_group': settings.default_supplier_group, 'payment_terms': settings.default_payment_terms @@ -3097,10 +3506,11 @@ def match_similar_suppliers(): consider_azeri = settings.consider_azeri_chars # Get all suppliers with "New" status from E-Taxes Suppliers - etaxes_suppliers = frappe.get_all('E-Taxes Suppliers', + etaxes_suppliers = frappe.get_all('E-Taxes Suppliers', filters={'status': 'New'}, - fields=['name', 'etaxes_party_name', 'etaxes_tax_id']) - + fields=['name', 'etaxes_party_name', 'etaxes_tax_id', + 'is_individual', 'fin']) + # Get existing mappings existing_mappings = {} for mapping in settings.supplier_mappings: @@ -3167,11 +3577,15 @@ def match_similar_suppliers(): # If row already exists, update its erp_supplier value doc.supplier_mappings[existing_row].erp_supplier = best_match.name doc.supplier_mappings[existing_row].mapping_type = 'Automatic' + doc.supplier_mappings[existing_row].is_individual = etaxes_supplier.get('is_individual') or 0 + doc.supplier_mappings[existing_row].fin = etaxes_supplier.get('fin') or '' else: # If no row, add a new one doc.append('supplier_mappings', { 'etaxes_supplier_name': etaxes_supplier.etaxes_party_name, 'etaxes_tax_id': etaxes_supplier.etaxes_tax_id, + 'is_individual': etaxes_supplier.get('is_individual') or 0, + 'fin': etaxes_supplier.get('fin') or '', 'erp_supplier': best_match.name, 'mapping_type': 'Automatic' }) @@ -3378,54 +3792,78 @@ def create_unmapped_suppliers(): supplier_name = etaxes_supplier.etaxes_party_name if len(supplier_name) > 140: supplier_name = supplier_name[:140] - - # Check if such supplier with this name already exists - supplier_exists = frappe.db.exists('Supplier', {'supplier_name': supplier_name}) - - if supplier_exists: - continue - - # Create new supplier - supplier_doc = frappe.new_doc("Supplier") - - # Get individual settings + + is_individual = bool(etaxes_supplier.get('is_individual')) + fin = (etaxes_supplier.get('fin') or '').strip() individual_settings = mapping_settings.get(etaxes_supplier.etaxes_party_name, {}) - - # Set basic fields - supplier_doc.supplier_name = supplier_name - supplier_doc.supplier_type = "Company" # Can be configured if needed - - # Apply settings considering individual priorities - supplier_group = individual_settings.get('supplier_group', settings_doc.default_supplier_group) - if not frappe.db.exists('Supplier Group', supplier_group): - supplier_group = "All Supplier Groups" - supplier_doc.supplier_group = supplier_group - - # If tax_id specified, add it - if etaxes_supplier.etaxes_tax_id: - supplier_doc.tax_id = etaxes_supplier.etaxes_tax_id - - # If payment terms specified, add them - if 'payment_terms' in individual_settings: - supplier_doc.payment_terms = individual_settings['payment_terms'] - elif hasattr(settings_doc, 'default_payment_terms') and settings_doc.default_payment_terms: - supplier_doc.payment_terms = settings_doc.default_payment_terms - - # Save supplier - supplier_doc.insert() - - # Update mapping in settings + + erp_supplier_name = None + + if is_individual: + # Purchase-act seller (physical person): match an existing + # Individual supplier by FIN, otherwise create one. + if fin: + erp_supplier_name = frappe.db.get_value('Supplier', {'fin': fin}, 'name') + if not erp_supplier_name: + supplier_doc = frappe.new_doc("Supplier") + supplier_doc.supplier_name = supplier_name + supplier_doc.supplier_type = "Individual" + supplier_group = individual_settings.get('supplier_group', settings_doc.default_supplier_group) + if not frappe.db.exists('Supplier Group', supplier_group): + supplier_group = "All Supplier Groups" + supplier_doc.supplier_group = supplier_group + supplier_doc.fin = fin + if etaxes_supplier.get('passport_serial_number'): + supplier_doc.passport_serial_number = etaxes_supplier.passport_serial_number + if etaxes_supplier.get('first_name'): + supplier_doc.first_name_individual = etaxes_supplier.first_name + if etaxes_supplier.get('last_name'): + supplier_doc.last_name_individual = etaxes_supplier.last_name + if etaxes_supplier.get('phone_number'): + supplier_doc.phone_number_individual = etaxes_supplier.phone_number + if etaxes_supplier.get('date_of_birth'): + supplier_doc.date_of_birth = etaxes_supplier.date_of_birth + if 'payment_terms' in individual_settings: + supplier_doc.payment_terms = individual_settings['payment_terms'] + elif getattr(settings_doc, 'default_payment_terms', None): + supplier_doc.payment_terms = settings_doc.default_payment_terms + supplier_doc.insert() + erp_supplier_name = supplier_doc.name + else: + # Company (existing behavior): skip if a same-name supplier exists. + if frappe.db.exists('Supplier', {'supplier_name': supplier_name}): + continue + supplier_doc = frappe.new_doc("Supplier") + supplier_doc.supplier_name = supplier_name + supplier_doc.supplier_type = "Company" # Can be configured if needed + supplier_group = individual_settings.get('supplier_group', settings_doc.default_supplier_group) + if not frappe.db.exists('Supplier Group', supplier_group): + supplier_group = "All Supplier Groups" + supplier_doc.supplier_group = supplier_group + if etaxes_supplier.etaxes_tax_id: + supplier_doc.tax_id = etaxes_supplier.etaxes_tax_id + if 'payment_terms' in individual_settings: + supplier_doc.payment_terms = individual_settings['payment_terms'] + elif getattr(settings_doc, 'default_payment_terms', None): + supplier_doc.payment_terms = settings_doc.default_payment_terms + supplier_doc.insert() + erp_supplier_name = supplier_doc.name + + # Update mapping in settings (carry is_individual + fin so act + # import can match the seller by FIN). for mapping in settings_doc.supplier_mappings: if mapping.etaxes_supplier_name == etaxes_supplier.etaxes_party_name: - mapping.erp_supplier = supplier_doc.name + mapping.erp_supplier = erp_supplier_name mapping.mapping_type = 'Automatic' + mapping.is_individual = 1 if is_individual else 0 + mapping.fin = fin break - + # Update E-Taxes Suppliers status etaxes_supplier.status = 'Mapped' - etaxes_supplier.mapped_supplier = supplier_doc.name + etaxes_supplier.mapped_supplier = erp_supplier_name etaxes_supplier.save() - + created_count += 1 except Exception as e: @@ -3981,11 +4419,144 @@ def process_single_invoice_for_reference_data(token, invoice_id, source_type='pu 'success': False, 'message': "An unknown error occurred processing this invoice" } - + + +@frappe.whitelist() +def process_act_parties_from_list(act_list_data): + """Create E-Taxes Suppliers (individuals) from purchase-act sellers. + + The act find.sent list already contains the full `seller` block (a physical + person), so no detail fetch is needed. Entries are marked is_individual=1 and + carry the FIN — that is what act import matches on. Dedup is by FIN. + """ + record_etaxes_activity() + try: + if isinstance(act_list_data, str): + act_list_data = json.loads(act_list_data) + + suppliers_created = 0 + seen_fins = set() + + for act in act_list_data: + seller = act.get("seller") or {} + fin = (seller.get("fin") or "").strip() + if not fin or fin in seen_fins: + continue + seen_fins.add(fin) + + # Already staged with this FIN? skip (dedup by FIN, not by name) + if frappe.db.exists("E-Taxes Suppliers", {"fin": fin}): + continue + + full_name = (seller.get("fullName") or "").strip() + if not full_name: + full_name = " ".join( + filter(None, [seller.get("firstname"), seller.get("lastname")]) + ).strip() or fin + full_name = full_name[:140] + + # autoname is etaxes_party_name (the supplier name) -> ensure unique. + # Two different people can share a name, so append the FIN on collision. + party_name = full_name + if frappe.db.exists("E-Taxes Suppliers", party_name): + party_name = f"{full_name[:120]} ({fin})" + + dob = seller.get("dateOfBirth") + try: + dob = getdate(dob) if dob else None + except Exception: + dob = None + + try: + doc = frappe.get_doc({ + "doctype": "E-Taxes Suppliers", + "etaxes_party_name": party_name, + "etaxes_party_type": "Sender", + "etaxes_address": (seller.get("address") or "")[:140], + "source_invoice": act.get("serialNumber") or act.get("id") or "", + "status": "New", + "is_individual": 1, + "fin": fin, + "passport_serial_number": seller.get("passportSerialNumber") or "", + "first_name": seller.get("firstname") or "", + "last_name": seller.get("lastname") or "", + "phone_number": seller.get("phoneNumber") or "", + "date_of_birth": dob, + }) + doc.insert(ignore_permissions=True, ignore_if_duplicate=True) + suppliers_created += 1 + except Exception as e: + frappe.log_error(f"Act party create error (fin={fin}): {e}", "Act Party Load Error") + + if suppliers_created: + frappe.db.commit() + + return {"success": True, "suppliers_created": suppliers_created} + except Exception as e: + frappe.log_error( + f"process_act_parties_from_list error: {e}\n{frappe.get_traceback()}", + "Act Party Load Error", + ) + return {"success": False, "message": "Error processing act parties"} + + +@frappe.whitelist() +def process_single_act_for_reference_data(token, act_id, load_items=1, load_units=1): + """Fetch one purchase act's detail and create its E-Taxes Item / Unit rows. + + Act detail items use the same nested `productGroup` shape as invoices, so the + item/unit creation in create_reference_data_from_single_invoice is reused + (parties disabled — act sellers are handled from the list).""" + record_etaxes_activity() + try: + details = get_purchase_act_details(token, act_id) + + if isinstance(details, dict) and details.get('error') == 'unauthorized': + token_result = refresh_token_from_asan_login() + if token_result.get('success'): + new_token = token_result.get('token') + details = get_purchase_act_details(new_token, act_id) + if isinstance(details, dict) and 'error' in details: + return { + 'success': False, + 'error': details.get('error'), + 'message': details.get('message', 'Failed to get act details'), + 'new_token': new_token, + } + else: + return { + 'success': False, + 'error': 'unauthorized', + 'message': 'Authentication required. Please login again.', + } + + if isinstance(details, dict) and 'error' in details: + return { + 'success': False, + 'error': details.get('error'), + 'message': details.get('message', 'Failed to get act details'), + } + + return create_reference_data_from_single_invoice( + details, 'purchase', + load_items=int(load_items), load_units=int(load_units), + load_customers=0, load_suppliers=0, + from_act=1, + ) + + except Exception as e: + frappe.log_error(f"Error in process_single_act_for_reference_data: {str(e)}", "Process Single Act Error") + return { + 'success': False, + 'message': "An unknown error occurred processing this act", + } + + @frappe.whitelist() def create_reference_data_from_single_invoice(invoice_details, source_type, load_items=1, load_units=1, - load_customers=1, load_suppliers=1): + load_customers=1, load_suppliers=1, + from_act=0): """Создает доктайпы из одного инвойса с EQM кодами - оптимизированная версия""" try: stats = { @@ -4007,7 +4578,7 @@ def create_reference_data_from_single_invoice(invoice_details, source_type, # === СОЗДАНИЕ ТОВАРОВ С EQM КОДАМИ === if int(load_items): processed_items = set() - for item in items: + for idx, item in enumerate(items): item_name = item.get('productName', '').strip() if not item_name: continue @@ -4027,8 +4598,12 @@ def create_reference_data_from_single_invoice(invoice_details, source_type, continue try: - # Создаем товар - item_code = item.get('itemId', '') or f"CODE-{serial_number}" + # etaxes_item_code is UNIQUE. Invoice items carry a distinct + # itemId, but act items don't — so a shared f"CODE-{serial}" + # collides and silently drops all but the first act line. + # Use the act line's detailOid, else a per-index code. + item_code = (item.get('itemId') or item.get('detailOid') + or f"CODE-{serial_number}-{idx}") # Обрабатываем группу товаров product_group = item.get('productGroup', {}) @@ -4081,7 +4656,14 @@ def create_reference_data_from_single_invoice(invoice_details, source_type, frappe.log_error(f"[EQM DEBUG] Final eqm_code value to save: '{eqm_code}' for item {item_name}", "EQM Code Final") # Определяем, является ли товар услугой - is_service = 1 if product_group_type == 'service' else 0 + # Purchase acts are always for physical goods (agricultural + # products, scrap, rawhide, etc.). e-taxes still tags their + # product group as "service" (the retail-sale service), so + # force non-service for act items → they default to stock. + if from_act: + is_service = 0 + else: + is_service = 1 if product_group_type == 'service' else 0 # Определяем источники is_from_purchase = 1 if source_type == 'purchase' else 0 @@ -4339,6 +4921,67 @@ def load_single_invoice_batch(date_from, date_to, offset=0, max_count=200): 'message': "An unknown error occurred, please try again in a few minutes." } +@frappe.whitelist() +def load_single_act_batch(date_from, date_to, offset=0, max_count=100): + """Загружает одну порцию закупочных актов (alış aktı) из find.sent. + + Возвращает тот же контракт, что и load_single_invoice_batch, чтобы + _ref_fetch_source мог работать с актами так же, как с инвойсами. + """ + record_etaxes_activity() + + try: + asan_login_settings = get_default_asan_login() + if not asan_login_settings.get('found'): + return {'success': False, 'message': 'No Asan Login settings found'} + + token = asan_login_settings['main_token'] + + try: + max_count = int(max_count) + except (ValueError, TypeError): + max_count = 100 + try: + offset = int(offset) + except (ValueError, TypeError): + offset = 0 + + filters = { + "creationDateFrom": date_from, + "creationDateTo": date_to, + "maxCount": max_count, + "offset": offset, + } + + response = get_purchase_acts(token, json.dumps(filters)) + + if 'error' in response: + return { + 'success': False, + 'error': response.get('error'), + 'message': response.get('message', 'Failed to retrieve purchase acts') + } + + acts = response.get('acts', []) or [] + hasMore = response.get('hasMore', False) + + return { + 'success': True, + 'invoices': acts, + 'hasMore': hasMore, + 'token': token, + 'offset': offset, + 'total': 0, + 'loaded': len(acts) + } + + except Exception as e: + frappe.log_error(f"Error in load_single_act_batch: {str(e)}\n{frappe.get_traceback()}", "Load Act Batch Error") + return { + 'success': False, + 'message': "An unknown error occurred, please try again in a few minutes." + } + @frappe.whitelist() def load_single_sales_invoice_batch(date_from, date_to, offset=0, max_count=200): """Загружает одну порцию инвойсов из outbox (sales)""" @@ -4594,6 +5237,140 @@ def _process_bulk_purchase_import(invoice_ids, token, warehouse, user, bg_job_id ) +@frappe.whitelist() +def import_bulk_purchase_documents(documents, token, warehouse=None): + """Enqueue a bulk import of mixed purchase documents (invoices and/or acts). + + `documents` is a list of {"id": , "kind": "invoice"|"act"}. Realtime + progress reuses the purchase_import_progress / purchase_import_complete events + so the existing list-view summary handler works for both kinds.""" + if isinstance(documents, str): + documents = json.loads(documents) + + user = frappe.session.user + job_id = background_tasks.new_job_id() + + frappe.enqueue( + _process_bulk_purchase_documents, + documents=documents, + token=token, + warehouse=warehouse, + user=user, + bg_job_id=job_id, + queue="default", + timeout=7200, + ) + + return {"success": True, "enqueued": True, "total": len(documents), "job_id": job_id} + + +def _process_bulk_purchase_documents(documents, token, warehouse, user, bg_job_id=None): + """Background job: import each purchase document (invoice or act) by kind.""" + job_id = bg_job_id + frappe.set_user(user) + record_etaxes_activity() + + total = len(documents) + imported_count = 0 + errors = [] + + background_tasks.register_task(job_id, user, "purchase_import", "Importing purchase documents", total=total) + + for idx, doc_ref in enumerate(documents): + if job_id and background_tasks.is_cancel_requested(job_id): + background_tasks.finish_task(job_id, user, "cancelled") + frappe.publish_realtime( + "purchase_import_complete", + {"total": total, "imported": imported_count, "errors": errors, "cancelled": True}, + user=user, + ) + return + + doc_id = doc_ref.get("id") if isinstance(doc_ref, dict) else doc_ref + kind = (doc_ref.get("kind") if isinstance(doc_ref, dict) else "invoice") or "invoice" + is_act = (kind == "act") + + background_tasks.update_task(job_id, user, current=idx, total=total, + message=f"Importing {'act' if is_act else 'invoice'} {idx + 1}/{total}") + + # ---- fetch detail (one token refresh on 401) ---- + fetch = get_purchase_act_details if is_act else get_invoice_details + try: + details = fetch(token, doc_id) + except Exception: + details = None + + if not details or details.get("error"): + if details and details.get("error") == "unauthorized": + asan_login = get_default_asan_login() + if asan_login.get("found") and asan_login.get("main_token"): + token = asan_login["main_token"] + details = fetch(token, doc_id) + if not details or details.get("error"): + error_msg = details.get("message", "Failed to fetch document details") if details else "Empty response" + errors.append({"invoice_id": doc_id, "kind": kind, "error": error_msg}) + frappe.db.commit() + continue + + # ---- schedule date from createdAt / creationDate ---- + schedule_date = None + created_at = details.get("createdAt") or details.get("creationDate") or details.get("date") + if created_at: + try: + schedule_date = datetime.datetime.strptime(str(created_at)[:10], "%Y-%m-%d").strftime("%Y-%m-%d") + except Exception: + pass + + # ---- import (route by kind) ---- + if is_act: + result = import_act_with_mapping(act_data=details, schedule_date=schedule_date, warehouse=warehouse) + else: + result = import_invoice_with_mapping(invoice_data=details, purchase_order_name=None, + schedule_date=schedule_date, warehouse=warehouse) + + if result and result.get("success"): + # The invoice path writes its tracking record post-success; acts do + # this inside import_act_with_mapping. + if not is_act: + try: + sender_name = details.get("sender", {}).get("name", "") if details.get("sender") else "" + total_amount = details.get("totalAmount", 0) or details.get("amount", 0) + etaxes_result = create_etaxes_purchase(doc_id, schedule_date, sender_name, total_amount) + if etaxes_result and etaxes_result.get("success"): + link_purchase_order_to_etaxes(result["purchase_order"], etaxes_result["name"]) + except Exception: + pass + imported_count += 1 + else: + error_msg = result.get("message", "Unknown import error") if result else "Empty result" + error_entry = { + "invoice_id": doc_id, + "kind": kind, + "serial_number": details.get("serialNumber") or "", + "error": error_msg, + } + if result: + for key in ("unmatched_items", "unmatched_parties", "unmatched_units", "unmatched_suppliers"): + if result.get(key): + error_entry[key] = result[key] + errors.append(error_entry) + + frappe.db.commit() + + frappe.publish_realtime( + "purchase_import_progress", + {"current": idx + 1, "total": total}, + user=user, + ) + + background_tasks.finish_task(job_id, user, "done") + frappe.publish_realtime( + "purchase_import_complete", + {"total": total, "imported": imported_count, "errors": errors}, + user=user, + ) + + # ======= REFERENCE DATA LOADING (BACKGROUND) ======= # # Previously the whole "Data Loading" flow (fetch invoices -> create parties -> @@ -4608,7 +5385,8 @@ _REF_FETCH_PAGE_CAP = 500 # safety net against a never-ending hasMore (≤100k @frappe.whitelist() def start_reference_data_loading_bg(date_from, date_to, load_items=0, - load_customers=0, load_suppliers=0, load_units=0): + load_customers=0, load_suppliers=0, load_units=0, + load_acts=0, load_inbox=1, load_outbox=1): """Enqueue the reference-data loading worker and return its job_id. `date_from` / `date_to` arrive as plain dates (YYYY-MM-DD) from the dialog; @@ -4619,9 +5397,15 @@ def start_reference_data_loading_bg(date_from, date_to, load_items=0, load_customers = 1 if cint(load_customers) else 0 load_suppliers = 1 if cint(load_suppliers) else 0 load_units = 1 if cint(load_units) else 0 + load_acts = 1 if cint(load_acts) else 0 + load_inbox = 1 if cint(load_inbox) else 0 + load_outbox = 1 if cint(load_outbox) else 0 + # Two independent groups: WHAT to load (data types) and WHERE from (sources). if not (load_items or load_customers or load_suppliers or load_units): return {"success": False, "message": "Please select at least one data type to load."} + if not (load_inbox or load_outbox or load_acts): + return {"success": False, "message": "Please select at least one source to load from."} try: df = getdate(date_from) @@ -4645,6 +5429,9 @@ def start_reference_data_loading_bg(date_from, date_to, load_items=0, load_customers=load_customers, load_suppliers=load_suppliers, load_units=load_units, + load_acts=load_acts, + load_inbox=load_inbox, + load_outbox=load_outbox, user=frappe.session.user, bg_job_id=job_id, # NOT `job_id`: that name is reserved by frappe.enqueue queue="default", @@ -4654,12 +5441,16 @@ def start_reference_data_loading_bg(date_from, date_to, load_items=0, def _ref_fetch_source(date_from, date_to, source, job_id, user, all_invoices, fatal): - """Paginate one source (inbox/outbox) into `all_invoices`. + """Paginate one source (inbox/outbox/acts) into `all_invoices`. - Returns (ok, token). On a failure, inbox is fatal (ok=False), outbox is not - (ok=True, we just stop with whatever we have) — mirroring the old JS logic.""" - loader = load_single_invoice_batch if source == "inbox" else load_single_sales_invoice_batch - label = "purchase" if source == "inbox" else "sales" + Returns (ok, token). ok is None when cancelled mid-fetch. On a fetch failure, + returns ok=False if fatal else ok=True (stop with whatever we have).""" + if source == "inbox": + loader, label = load_single_invoice_batch, "purchase invoices" + elif source == "acts": + loader, label = load_single_act_batch, "purchase acts" + else: + loader, label = load_single_sales_invoice_batch, "sales invoices" token = None offset = 0 for _page in range(_REF_FETCH_PAGE_CAP): @@ -4675,16 +5466,18 @@ def _ref_fetch_source(date_from, date_to, source, job_id, user, all_invoices, fa all_invoices.extend(batch) background_tasks.update_task( job_id, user, - message=f"Finding {label} invoices: {len(all_invoices)} found...", + message=f"Finding {label}: {len(all_invoices)} found...", ) if not (result.get("hasMore") and batch): break - offset += 200 + # advance by the actual page size (inbox/outbox=200, acts=100) + offset += len(batch) return True, token def _process_reference_data_loading(date_from, date_to, load_items, load_customers, - load_suppliers, load_units, user, bg_job_id=None): + load_suppliers, load_units, user, bg_job_id=None, + load_acts=0, load_inbox=1, load_outbox=1): """Background worker — fetch invoices, then create parties + items/units, publishing progress to the Background Tasks registry the whole way.""" # `bg_job_id` (not `job_id`) because frappe.enqueue reserves the `job_id` kwarg @@ -4712,32 +5505,34 @@ def _process_reference_data_loading(date_from, date_to, load_items, load_custome ) try: - # ---- Phase 1: fetch invoice lists ---- - background_tasks.update_task(job_id, user, message="Finding invoices...") + # ---- Phase 1: fetch document lists from the selected sources ---- + # Sources (inbox / outbox / acts) are chosen explicitly in the dialog; the + # data-type flags decide what gets created from each. All fetches are + # non-fatal so one failing source does not abort the others. + background_tasks.update_task(job_id, user, message="Finding documents...") all_invoices = [] token = None - ok, tok = _ref_fetch_source(date_from, date_to, "inbox", job_id, user, all_invoices, fatal=True) - token = token or tok - if ok is None: # cancelled mid-fetch - return _cancel_and_finish() - if not ok: - background_tasks.finish_task(job_id, user, "error", "Failed to fetch purchase invoices.") - frappe.publish_realtime( - REFERENCE_LOADING_COMPLETE_EVENT, - {"error": "fetch_failed", "message": "Failed to fetch purchase invoices."}, - user=user, - ) - return + if load_inbox and not _is_cancelled(): + ok, tok = _ref_fetch_source(date_from, date_to, "inbox", job_id, user, all_invoices, fatal=False) + token = token or tok + if ok is None: # cancelled mid-fetch + return _cancel_and_finish() - # outbox (sales) only matters for customers / items / units - outbox_needed = bool(load_customers or load_items or load_units) - if outbox_needed and not _is_cancelled(): + # outbox (sales invoices) + if load_outbox and not _is_cancelled(): _ok2, tok2 = _ref_fetch_source(date_from, date_to, "outbox", job_id, user, all_invoices, fatal=False) token = token or tok2 if _ok2 is None: return _cancel_and_finish() + # purchase acts (alış aktı): individual sellers + their items/units + if load_acts and not _is_cancelled(): + _ok3, tok3 = _ref_fetch_source(date_from, date_to, "acts", job_id, user, all_invoices, fatal=False) + token = token or tok3 + if _ok3 is None: + return _cancel_and_finish() + if _is_cancelled(): return _cancel_and_finish() @@ -4789,7 +5584,26 @@ def _process_reference_data_loading(date_from, date_to, load_items, load_custome else f"Reading counterparties... {made} created"), ) - # ---- Phase 3: items/units from invoice details (per invoice) ---- + # ---- Phase 2b: individuals from purchase acts (seller block in list) ---- + # Acts carry the seller (a physical person) directly in the find.sent list, + # so they don't need the detail fetch to create the E-Taxes Suppliers entry. + # process_invoice_parties_from_list above ignores acts (no sender/receiver), + # so handle them here. Gated on load_suppliers (individuals are suppliers). + if load_acts and load_suppliers: + act_list = [inv for inv in all_invoices if inv.get("_source") == "acts"] + for start in range(0, len(act_list), 500): + if _is_cancelled(): + return _cancel_and_finish() + ares = process_act_parties_from_list(act_list[start:start + 500]) + if isinstance(ares, dict) and ares.get("success"): + accumulated["suppliers_created"] += ares.get("suppliers_created", 0) or 0 + made = accumulated["customers_created"] + accumulated["suppliers_created"] + background_tasks.update_task( + job_id, user, total=grand_total, + message=f"Counterparties: {made} created", + ) + + # ---- Phase 3: items/units from invoice/act details (per document) ---- if needs_detail: # Parties were already handled from the list above; disable them here # so we don't do the work twice. @@ -4797,17 +5611,26 @@ def _process_reference_data_loading(date_from, date_to, load_items, load_custome if _is_cancelled(): return _cancel_and_finish() source = inv.get("_source", "inbox") - source_type = "sales" if source == "outbox" else "purchase" - res = process_single_invoice_for_reference_data( - token, inv.get("id"), source_type, - load_items=load_items, load_units=load_units, - load_customers=0, load_suppliers=0, - ) + if source == "acts": + res = process_single_act_for_reference_data( + token, inv.get("id"), + load_items=load_items, load_units=load_units, + ) + else: + source_type = "sales" if source == "outbox" else "purchase" + res = process_single_invoice_for_reference_data( + token, inv.get("id"), source_type, + load_items=load_items, load_units=load_units, + load_customers=0, load_suppliers=0, + ) if isinstance(res, dict): if res.get("new_token"): token = res["new_token"] - accumulated["items_created"] += res.get("items_created", 0) or 0 - accumulated["units_created"] += res.get("units_created", 0) or 0 + # create_reference_data_from_single_invoice returns counts nested + # under "stats"; tolerate both shapes. + _stats = res.get("stats") or res + accumulated["items_created"] += _stats.get("items_created", 0) or 0 + accumulated["units_created"] += _stats.get("units_created", 0) or 0 if idx % 5 == 0 or idx == total_invoices - 1: background_tasks.update_task( job_id, user, current=idx + 1, total=grand_total, diff --git a/invoice_az/client/purchase_invoice.js b/invoice_az/client/purchase_invoice.js index 31f38f3..3624e85 100644 --- a/invoice_az/client/purchase_invoice.js +++ b/invoice_az/client/purchase_invoice.js @@ -635,22 +635,25 @@ frappe.ui.form.on('Purchase Invoice', { return; } - // Validate Tax Type for all items - let missing_tax_type = []; + // Tax Type is only required for a Purchase Act (matches the field's + // mandatory_depends_on). EQF / Import documents don't use it. + if (frm.doc.act_type === 'Purchase Act') { + let missing_tax_type = []; - for (let item of frm.doc.items) { - if (!item.tax_type) { - missing_tax_type.push(item.item_code); + for (let item of frm.doc.items) { + if (!item.tax_type) { + missing_tax_type.push(item.item_code); + } } - } - if (missing_tax_type.length > 0) { - frappe.msgprint({ - title: __('Missing Tax Type'), - indicator: 'red', - message: __('Please select Tax Type for items: {0}', [missing_tax_type.join(', ')]) - }); - frappe.validated = false; + if (missing_tax_type.length > 0) { + frappe.msgprint({ + title: __('Missing Tax Type'), + indicator: 'red', + message: __('Please select Tax Type for items: {0}', [missing_tax_type.join(', ')]) + }); + frappe.validated = false; + } } // NOTE: Metal scrap validation removed from client-side to avoid duplicate error messages diff --git a/invoice_az/client/purchase_order_list.js b/invoice_az/client/purchase_order_list.js index 7c5c963..7643250 100644 --- a/invoice_az/client/purchase_order_list.js +++ b/invoice_az/client/purchase_order_list.js @@ -873,6 +873,14 @@ ETaxes.import = { fieldtype: 'Section Break', label: __('Settings') }, + { + fieldname: 'source', + fieldtype: 'Select', + label: __('What to import'), + options: 'Invoices\nActs\nBoth', + default: 'Invoices', + reqd: true + }, { fieldname: 'warehouse', fieldtype: 'Link', @@ -924,9 +932,10 @@ ETaxes.import = { const fromDate = moment(values.creationDateFrom).format('DD-MM-YYYY 00:00'); const toDate = moment(values.creationDateTo).format('DD-MM-YYYY 23:59'); - + const source = (values.source || 'Invoices').toLowerCase(); + d.hide(); - ETaxes.import.loadInvoices(fromDate, toDate, values.warehouse); + ETaxes.import.loadInvoices(fromDate, toDate, values.warehouse, source); } }); @@ -935,10 +944,16 @@ ETaxes.import = { }, // Загрузить инвойсы с фильтрацией дубликатов - loadInvoices: function(fromDate, toDate, warehouse, accumulatedInvoices = [], offset = 0) { + loadInvoices: function(fromDate, toDate, warehouse, source = 'invoices', accumulatedInvoices = [], offset = 0) { ETaxes.cancelLoading = false; $(document).off('progress-cancel.etaxes_import'); - + + // Acts-only: skip the invoice fetch and go straight to acts. + if (source === 'acts') { + ETaxes.import.loadActs(fromDate, toDate, warehouse, source, [], [], 0); + return; + } + if (offset === 0) { frappe.show_alert({ message: __('Fetching E-Taxes invoices...'), @@ -984,14 +999,20 @@ ETaxes.import = { if (hasMore && currentInvoices.length > 0) { const newOffset = offset + currentInvoices.length; - ETaxes.import.loadInvoices(fromDate, toDate, warehouse, allInvoices, newOffset); + ETaxes.import.loadInvoices(fromDate, toDate, warehouse, source, allInvoices, newOffset); } else { - if (allInvoices.length > 0) { + // Tag invoices so the selection + import can tell them from acts. + allInvoices.forEach(function(inv) { inv._doc_kind = 'invoice'; }); + + if (source === 'both') { + // Also fetch acts, then show the combined list. + ETaxes.import.loadActs(fromDate, toDate, warehouse, source, allInvoices, [], 0); + } else if (allInvoices.length > 0) { frappe.show_alert({ message: __('Processing ') + allInvoices.length + __(' invoices...'), indicator: 'blue' }, 3); - + ETaxes.import.filterDuplicates(allInvoices, mainToken, warehouse); } else { frappe.msgprint({ @@ -1006,7 +1027,7 @@ ETaxes.import = { __('Your E-Taxes session has expired. Would you like to authenticate now?'), function() { ETaxes.auth.startProcess(function() { - ETaxes.import.loadInvoices(fromDate, toDate, warehouse); + ETaxes.import.loadInvoices(fromDate, toDate, warehouse, source); }); }, function() { @@ -1042,7 +1063,88 @@ ETaxes.import = { } }); }, - + + // Загрузить закупочные акты (alış aktı) постранично, затем (с учётом уже + // загруженных инвойсов в режиме «Both») отфильтровать дубликаты и показать. + loadActs: function(fromDate, toDate, warehouse, source, existingDocs = [], accumulatedActs = [], offset = 0) { + ETaxes.cancelLoading = false; + + if (offset === 0) { + frappe.show_alert({ message: __('Fetching E-Taxes purchase acts...'), indicator: 'blue' }, 3); + } + + ETaxes.utils.getDefaultLogin(function(loginResponse) { + if (ETaxes.cancelLoading) return; + + if (!(loginResponse && loginResponse.found && loginResponse.main_token)) { + frappe.msgprint({ title: __('Error'), indicator: 'red', + message: __('E-Taxes authentication settings not found') }); + return; + } + const mainToken = loginResponse.main_token; + + frappe.call({ + method: 'invoice_az.api.get_purchase_acts', + args: { + token: mainToken, + filters: JSON.stringify({ + creationDateFrom: fromDate, + creationDateTo: toDate, + maxCount: 100, + offset: offset + }) + }, + callback: function(r) { + if (ETaxes.cancelLoading) return; + + if (r.message && !r.message.error) { + const currentActs = r.message.acts || []; + const hasMore = r.message.hasMore || false; + const allActs = accumulatedActs.concat(currentActs); + + if (hasMore && currentActs.length > 0) { + ETaxes.import.loadActs(fromDate, toDate, warehouse, source, + existingDocs, allActs, offset + currentActs.length); + } else { + allActs.forEach(function(a) { a._doc_kind = 'act'; }); + const combined = (existingDocs || []).concat(allActs); + if (combined.length > 0) { + frappe.show_alert({ + message: __('Processing ') + combined.length + __(' documents...'), + indicator: 'blue' + }, 3); + ETaxes.import.filterDuplicates(combined, mainToken, warehouse); + } else { + frappe.msgprint({ title: __('Information'), indicator: 'blue', + message: __('No documents found for the specified period') }); + } + } + } else if (r.message && r.message.error === 'unauthorized') { + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + ETaxes.auth.startProcess(function() { + ETaxes.import.loadActs(fromDate, toDate, warehouse, source, existingDocs, [], 0); + }); + }, + function() { + frappe.show_alert({ message: __('Authentication canceled. Operation cannot be completed.'), + indicator: 'red' }, 5); + } + ); + } else { + frappe.msgprint({ title: __('Error'), indicator: 'red', + message: r.message ? r.message.message : __('Error loading purchase acts') }); + } + }, + error: function(xhr, status, error) { + frappe.msgprint({ title: __('Network Error'), indicator: 'red', + message: __('Error fetching data from server: ') + (error || 'Unknown error') }); + } + }); + }); + }, + // Фильтрация дубликатов filterDuplicates: function(allInvoices, token, warehouse) { frappe.call({ @@ -1107,21 +1209,25 @@ ETaxes.import = { showInvoiceSelection: function(invoices, token, warehouse) { let invoiceTable = '
'; invoiceTable += '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + '' + ''; - - // Remember the serial from the selection list per invoice id. The detail - // endpoint sometimes returns it empty (e.g. old documents), so we fall - // back to this during import instead of showing the internal id. + + // Remember the serial + kind from the selection list per document id. The + // detail endpoint sometimes returns the serial empty (e.g. old documents), + // so we fall back to this during import instead of showing the internal id. ETaxes.import.serialByInvoiceId = {}; + ETaxes.import.kindByInvoiceId = {}; invoices.forEach(function(invoice) { + const kind = invoice._doc_kind || 'invoice'; const serialNumber = invoice.serialNumber || ''; ETaxes.import.serialByInvoiceId[String(invoice.id)] = serialNumber; + ETaxes.import.kindByInvoiceId[String(invoice.id)] = kind; let creationDate = ''; if (invoice.createdAt) { @@ -1129,15 +1235,22 @@ ETaxes.import = { } else if (invoice.creationDate) { creationDate = moment(invoice.creationDate).format('DD.MM.YYYY'); } - - const senderName = invoice.sender ? invoice.sender.name : (invoice.senderName || ''); + + let partyName; + if (kind === 'act') { + partyName = (invoice.seller && invoice.seller.fullName) ? invoice.seller.fullName : (invoice.sellerName || ''); + } else { + partyName = invoice.sender ? invoice.sender.name : (invoice.senderName || ''); + } const amount = invoice.totalAmount || invoice.amount || 0; - + const typeLabel = (kind === 'act') ? __('Act') : __('Invoice'); + invoiceTable += '' + - '' + + '' + + '' + '' + '' + - '' + + '' + '' + ''; }); @@ -1171,23 +1284,26 @@ ETaxes.import = { ], primary_action_label: __('Load selected'), primary_action: function() { - const selectedInvoiceIds = []; + const selectedDocs = []; d.$wrapper.find('.select-invoice:checked').each(function() { - selectedInvoiceIds.push($(this).data('id')); + selectedDocs.push({ + id: $(this).data('id'), + kind: $(this).data('kind') || 'invoice' + }); }); - - if (selectedInvoiceIds.length === 0) { + + if (selectedDocs.length === 0) { frappe.msgprint({ title: __('Warning'), indicator: 'orange', - message: __('No invoices selected') + message: __('No documents selected') }); return; } - + d.hide(); ETaxes.cancelLoading = false; - ETaxes.import.loadSelectedInvoicesSocketIO(selectedInvoiceIds, token, warehouse); + ETaxes.import.loadSelectedInvoicesSocketIO(selectedDocs, token, warehouse); }, secondary_action_label: __('Cancel'), secondary_action: function() { @@ -1478,10 +1594,10 @@ ETaxes.import = { }, ETaxes.PROGRESS_UPDATE_DELAY); }, - // Socket.IO bulk import - loadSelectedInvoicesSocketIO: function(invoiceIds, token, warehouse) { + // Socket.IO bulk import (documents = [{id, kind:'invoice'|'act'}]) + loadSelectedInvoicesSocketIO: function(documents, token, warehouse) { ETaxes.loadingErrors = []; - const total = invoiceIds.length; + const total = documents.length; // Защита от гонки realtime: 'complete' вызывает hide_progress() (обнуляет // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. @@ -1521,11 +1637,13 @@ ETaxes.import = { error_type: err.unmatched_items ? 'Unmapped Items' : err.unmatched_parties ? 'Unmapped Parties' : err.unmatched_units ? 'Unmapped Units' + : err.unmatched_suppliers ? 'Unmapped Suppliers' : 'Import Error', error_message: err.error || 'Unknown error', unmatched_items: err.unmatched_items, unmatched_parties: err.unmatched_parties, - unmatched_units: err.unmatched_units + unmatched_units: err.unmatched_units, + unmatched_suppliers: err.unmatched_suppliers }); }); @@ -1538,9 +1656,9 @@ ETaxes.import = { }); frappe.call({ - method: 'invoice_az.api.import_bulk_purchase_invoices', + method: 'invoice_az.api.import_bulk_purchase_documents', args: { - invoice_ids: JSON.stringify(invoiceIds), + documents: JSON.stringify(documents), token: token, warehouse: warehouse }, diff --git a/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.js b/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.js index 8917b2a..e1fafc0 100644 --- a/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.js +++ b/invoice_az/invoice_az/doctype/e_taxes_settings/e_taxes_settings.js @@ -592,8 +592,10 @@ frappe.ui.form.on('E-Taxes Settings', { row.etaxes_supplier_name = supplier.etaxes_party_name; row.etaxes_tax_id = supplier.etaxes_tax_id; - - if (supplier.supplier_group) + row.is_individual = supplier.is_individual ? 1 : 0; + row.fin = supplier.fin || ''; + + if (supplier.supplier_group) row.supplier_group = supplier.supplier_group; if (supplier.payment_terms) @@ -936,6 +938,40 @@ function show_load_dialog_with_summary(frm, summary) { label: __('Load Suppliers'), default: 1, description: __('Load suppliers from invoice senders') + }, + { + fieldname: 'sources_section', + fieldtype: 'Section Break', + label: __('Sources to Load From') + }, + { + fieldname: 'load_inbox', + fieldtype: 'Check', + label: __('Incoming Invoices'), + default: 1, + description: __('Purchase invoices from the e-taxes inbox') + }, + { + fieldname: 'src_col_break_1', + fieldtype: 'Column Break' + }, + { + fieldname: 'load_outbox', + fieldtype: 'Check', + label: __('Outgoing Invoices'), + default: 1, + description: __('Sales invoices from the e-taxes outbox') + }, + { + fieldname: 'src_col_break_2', + fieldtype: 'Column Break' + }, + { + fieldname: 'load_acts', + fieldtype: 'Check', + label: __('Purchase Acts'), + default: 0, + description: __('Purchase acts (alış aktı) — individual sellers identified by FIN') } ], size: 'large', @@ -943,11 +979,15 @@ function show_load_dialog_with_summary(frm, summary) { primary_action: function() { const values = d.get_values(); - // Validate selections + // Validate selections — need at least one data type AND one source. if (!values.load_items && !values.load_customers && !values.load_suppliers && !values.load_units) { frappe.msgprint(__('Please select at least one data type to load.')); return; } + if (!values.load_inbox && !values.load_outbox && !values.load_acts) { + frappe.msgprint(__('Please select at least one source to load from.')); + return; + } if (values.date_from > values.date_to) { frappe.msgprint(__('Date From cannot be later than Date To.')); @@ -959,22 +999,27 @@ function show_load_dialog_with_summary(frm, summary) { return; } - // Show confirmation with selected types + // Show confirmation with selected types + sources const selected_types = []; if (values.load_items) selected_types.push(__('Items')); if (values.load_customers) selected_types.push(__('Customers')); if (values.load_suppliers) selected_types.push(__('Suppliers')); if (values.load_units) selected_types.push(__('Units')); - + + const selected_sources = []; + if (values.load_inbox) selected_sources.push(__('Incoming Invoices')); + if (values.load_outbox) selected_sources.push(__('Outgoing Invoices')); + if (values.load_acts) selected_sources.push(__('Purchase Acts')); + // УЛУЧШЕНО: Более подробное сообщение о процессе frappe.confirm( - __('This will load {0} from E-Taxes for the period {1} to {2}.

' + + __('This will load {0} from {3} for the period {1} to {2}.

' + 'Process:
' + - '1. First, all invoices will be fetched (may take 1-2 minutes)
' + - '2. Then each invoice will be processed for reference data

' + - 'This operation may take several minutes depending on the number of invoices.

' + - 'Continue?', - [selected_types.join(', '), values.date_from, values.date_to]), + '1. First, all documents will be fetched (may take 1-2 minutes)
' + + '2. Then each document will be processed for reference data

' + + 'This operation may take several minutes depending on the number of documents.

' + + 'Continue?', + [selected_types.join(', '), values.date_from, values.date_to, selected_sources.join(', ')]), function() { d.hide(); start_reference_data_loading(frm, values); @@ -1065,7 +1110,10 @@ function start_reference_data_loading(frm, values) { load_items: values.load_items ? 1 : 0, load_customers: values.load_customers ? 1 : 0, load_suppliers: values.load_suppliers ? 1 : 0, - load_units: values.load_units ? 1 : 0 + load_units: values.load_units ? 1 : 0, + load_inbox: values.load_inbox ? 1 : 0, + load_outbox: values.load_outbox ? 1 : 0, + load_acts: values.load_acts ? 1 : 0 }, callback: function(r) { const msg = r.message || {}; diff --git a/invoice_az/invoice_az/doctype/e_taxes_supplier_mappings/e_taxes_supplier_mappings.json b/invoice_az/invoice_az/doctype/e_taxes_supplier_mappings/e_taxes_supplier_mappings.json index c820727..06fa5af 100644 --- a/invoice_az/invoice_az/doctype/e_taxes_supplier_mappings/e_taxes_supplier_mappings.json +++ b/invoice_az/invoice_az/doctype/e_taxes_supplier_mappings/e_taxes_supplier_mappings.json @@ -6,6 +6,8 @@ "field_order": [ "etaxes_supplier_name", "etaxes_tax_id", + "is_individual", + "fin", "erp_supplier", "supplier_group", "payment_terms", @@ -25,6 +27,20 @@ "in_list_view": 1, "label": "Tax ID" }, + { + "default": "0", + "description": "Physical person (Purchase Act seller). Matched by FIN on act import.", + "fieldname": "is_individual", + "fieldtype": "Check", + "label": "Is Individual" + }, + { + "depends_on": "is_individual", + "fieldname": "fin", + "fieldtype": "Data", + "in_list_view": 1, + "label": "FIN" + }, { "fieldname": "erp_supplier", "fieldtype": "Link", diff --git a/invoice_az/invoice_az/doctype/e_taxes_suppliers/e_taxes_suppliers.json b/invoice_az/invoice_az/doctype/e_taxes_suppliers/e_taxes_suppliers.json index cbf4830..6786230 100644 --- a/invoice_az/invoice_az/doctype/e_taxes_suppliers/e_taxes_suppliers.json +++ b/invoice_az/invoice_az/doctype/e_taxes_suppliers/e_taxes_suppliers.json @@ -11,6 +11,14 @@ "etaxes_address", "source_invoice", "status", + "is_individual", + "individual_section", + "fin", + "passport_serial_number", + "first_name", + "last_name", + "phone_number", + "date_of_birth", "supplier_mapping_section", "mapped_supplier", "supplier_group", @@ -64,6 +72,58 @@ "options": "New\nMapped\nProcessing", "read_only": 1 }, + { + "default": "0", + "description": "Set for sellers imported from Purchase Acts (physical persons identified by FIN)", + "fieldname": "is_individual", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Individual", + "read_only": 1 + }, + { + "collapsible": 1, + "depends_on": "is_individual", + "fieldname": "individual_section", + "fieldtype": "Section Break", + "label": "Individual (Purchase Act Seller)" + }, + { + "fieldname": "fin", + "fieldtype": "Data", + "label": "FIN", + "read_only": 1 + }, + { + "fieldname": "passport_serial_number", + "fieldtype": "Data", + "label": "Passport Serial Number", + "read_only": 1 + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "read_only": 1 + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name", + "read_only": 1 + }, + { + "fieldname": "phone_number", + "fieldtype": "Data", + "label": "Phone Number", + "read_only": 1 + }, + { + "fieldname": "date_of_birth", + "fieldtype": "Date", + "label": "Date of Birth", + "read_only": 1 + }, { "fieldname": "supplier_mapping_section", "fieldtype": "Section Break",
' + __('Number') + '' + __('Date') + '' + __('Supplier') + '' + __('Type') + '' + __('Number') + '' + __('Date') + '' + __('Supplier') + '' + __('Amount') + '
' + typeLabel + '' + serialNumber + '' + creationDate + '' + senderName + '' + (partyName || '') + '' + ETaxes.utils.formatCurrency(amount) + '