diff --git a/invoice_az/api.py b/invoice_az/api.py index 3040a10..01be139 100644 --- a/invoice_az/api.py +++ b/invoice_az/api.py @@ -16,6 +16,75 @@ from .utils import resolve_customer_group, exceeds_one_year from .sales_api import get_sales_invoices +def split_serial_number(serial): + """Split an e-taxes serialNumber into (series, number). + + Two known formats, distinguished by the count of leading letters: + - 2 letters + 4 date digits (YYMM) + number, e.g. "MT240912345678" + -> series "MT2409", number "12345678" + - 4 letters + number, e.g. "EBEA057741" + -> series "EBEA", number "057741" + + Anything else (empty, unexpected prefix) -> the whole string becomes the + series and the number is left empty (per agreed fallback). + """ + serial = (serial or "").strip() + if not serial: + return "", "" + + match = re.match(r"^([A-Za-z]+)(\d+)$", serial) + if match: + letters, digits = match.group(1), match.group(2) + if len(letters) == 2 and len(digits) > 4: + # new format: letters + YYMM are the series, the rest is the number + return letters + digits[:4], digits[4:] + if len(letters) == 4: + # old format: letters are the series, digits are the number + return letters, digits + + # Unknown / unexpected format: keep the whole value as the series. + return serial, "" + + +def _rollback_import(po_name=None, etaxes_purchase_name=None, pi_name=None): + """Remove any partially-imported docs so a failed import leaves nothing behind. + + Used when a Purchase Order or its Purchase Invoice cannot be submitted: we + must not keep a half-imported draft or an orphan tracking record. Deletes in + dependency order (PI -> PO -> E-Taxes Purchase). Deleting the PO also fires + the on_trash hook that removes the linked E-Taxes Purchase, but we delete it + explicitly too in case linking never happened. + """ + # Purchase Invoice first (it depends on the PO) + if pi_name and frappe.db.exists("Purchase Invoice", pi_name): + try: + pi = frappe.get_doc("Purchase Invoice", pi_name) + if pi.docstatus == 1: + pi.cancel() + frappe.delete_doc("Purchase Invoice", pi_name, force=True, ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Failed to delete PI {pi_name}: {e}\n{frappe.get_traceback()}", "Import Rollback Error") + + # Purchase Order + if po_name and frappe.db.exists("Purchase Order", po_name): + try: + po = frappe.get_doc("Purchase Order", po_name) + if po.docstatus == 1: + po.cancel() + frappe.delete_doc("Purchase Order", po_name, force=True, ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Failed to delete PO {po_name}: {e}\n{frappe.get_traceback()}", "Import Rollback Error") + + # E-Taxes Purchase tracking record (may already be gone via PO on_trash hook) + if etaxes_purchase_name and frappe.db.exists("E-Taxes Purchase", etaxes_purchase_name): + try: + frappe.delete_doc("E-Taxes Purchase", etaxes_purchase_name, force=True, ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Failed to delete E-Taxes Purchase {etaxes_purchase_name}: {e}\n{frappe.get_traceback()}", "Import Rollback Error") + + frappe.db.commit() + + @frappe.whitelist() def get_invoices(token, filters=None): """Getting list of invoices with filtering options""" @@ -906,6 +975,15 @@ def create_purchase_invoice_from_order(purchase_order_name): # ДОБАВЛЕНО: Устанавливаем галочку is_taxes_doc для Purchase Invoice pi_doc.is_taxes_doc = 1 + + # Переносим комментарии и серию/номер из Purchase Order. + # remarks (коммент 1) ставим только если он непустой — иначе пусть + # ERPNext сам подставит стандартное "Against Supplier Invoice ...". + if po_doc.get("remarks"): + pi_doc.remarks = po_doc.remarks + pi_doc.etaxes_invoice_comment_2 = po_doc.get("etaxes_invoice_comment_2") + pi_doc.etaxes_invoice_series = po_doc.get("etaxes_invoice_series") + pi_doc.etaxes_invoice_number = po_doc.get("etaxes_invoice_number") # ДОБАВЛЕНО: Копируем налоги из Purchase Order if po_doc.taxes: @@ -1105,6 +1183,14 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule # Add/update additional fields from invoice po.title = f"Invoice {invoice_data.get('serialNumber', '')}" po.invoice_serial_number = invoice_data.get("serialNumber") or invoice_data.get("number", "") + + # 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 "" + po.etaxes_invoice_comment_2 = invoice_data.get("invoiceComment2") or "" + etaxes_series, etaxes_number = split_serial_number(invoice_data.get("serialNumber")) + po.etaxes_invoice_series = etaxes_series + po.etaxes_invoice_number = etaxes_number # Clear existing items if need to update them completely if purchase_order_name and invoice_data.get("items"): @@ -1296,52 +1382,57 @@ def import_invoice_with_mapping(invoice_data, purchase_order_name=None, schedule 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) + etaxes_purchase_result = create_etaxes_purchase( + invoice_id, date_to_use, sender_name, total, + series=etaxes_series, + number=etaxes_number, + comment=invoice_data.get("invoiceComment") or "", + comment2=invoice_data.get("invoiceComment2") or "", + ) + etaxes_purchase_name = None if etaxes_purchase_result and etaxes_purchase_result.get('success'): + etaxes_purchase_name = etaxes_purchase_result.get('name') # Устанавливаем поля E-Taxes ДО submit'а po.is_taxes_doc = 1 - po.taxes_doc = etaxes_purchase_result.get('name') + po.taxes_doc = etaxes_purchase_name # Сохраняем изменения po.save() - - # ИЗМЕНЕНИЕ: Делаем Submit для Purchase Order + + # Submit Purchase Order. Если документ не сабмитится — НЕ оставляем его: + # откатываем весь импорт (черновик PO + трекинг-запись) и сообщаем об ошибке. 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") + _rollback_import(po_name=po.name, etaxes_purchase_name=etaxes_purchase_name) return { 'success': False, 'message': f'Failed to submit Purchase Order: {str(e)}' } - - # ИЗМЕНЕНИЕ: Создаем Purchase Invoice + + # Создаём и сабмитим Purchase Invoice. Любая неудача на этом этапе — + # тоже полный откат (PO уже засабмичен, поэтому отменяем и удаляем его). + pi_name = None 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: - 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") + if not pi_name: + raise Exception("create_purchase_invoice_from_order returned no name") + 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 created, but Purchase Invoice creation failed.', - 'purchase_order': po.name + 'message': 'Invoice data 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: {str(e)}\n{frappe.get_traceback()}", "Submit PI 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: @@ -1373,37 +1464,42 @@ def get_etaxes_purchases(): } @frappe.whitelist() -def create_etaxes_purchase(etaxes_id, date, party, total): +def create_etaxes_purchase(etaxes_id, date, party, total, + series=None, number=None, comment=None, comment2=None): """Creates E-Taxes Purchase record for tracking imported invoices""" # Записываем активность record_etaxes_activity() - + try: # Normalize ID for storage etaxes_id = str(etaxes_id).strip() if etaxes_id else "" - + # Debug logging frappe.logger().info(f"Creating E-Taxes Purchase: ID={etaxes_id}, date={date}, party={party}, total={total}") - + # Check if record with such etaxes_id already exists existing = frappe.db.get_value('E-Taxes Purchase', {'etaxes_id': etaxes_id}, 'name') if existing: # Debug logging frappe.logger().info(f"E-Taxes Purchase already exists: {existing}") - + return { 'success': True, 'message': 'Record already exists', 'name': existing } - + # Create new record etaxes_purchase = frappe.new_doc('E-Taxes Purchase') etaxes_purchase.etaxes_id = etaxes_id etaxes_purchase.date = date etaxes_purchase.party = party etaxes_purchase.total = total - + etaxes_purchase.series = series or "" + etaxes_purchase.number = number or "" + etaxes_purchase.comment = comment or "" + etaxes_purchase.comment2 = comment2 or "" + etaxes_purchase.insert() frappe.db.commit() # Explicit commit @@ -4471,7 +4567,11 @@ def _process_bulk_purchase_import(invoice_ids, token, warehouse, user, bg_job_id imported_count += 1 else: error_msg = result.get("message", "Unknown import error") if result else "Empty result" - error_entry = {"invoice_id": invoice_id, "error": error_msg} + error_entry = { + "invoice_id": invoice_id, + "serial_number": details.get("serialNumber") or "", + "error": error_msg, + } if result: for key in ("unmatched_items", "unmatched_parties", "unmatched_units"): if result.get(key): diff --git a/invoice_az/client/purchase_order.js b/invoice_az/client/purchase_order.js index fb96b11..dc7e689 100644 --- a/invoice_az/client/purchase_order.js +++ b/invoice_az/client/purchase_order.js @@ -557,11 +557,12 @@ ETaxes.auth = { ETaxes.errors = { // Добавить ошибку в лог add: function(invoiceData, errorType, errorMessage, additionalData = {}) { + const invoiceData_ = invoiceData || {}; const errorEntry = { - invoice_id: invoiceData.id || 'Unknown', - serial_number: invoiceData.serialNumber || 'Unknown', - sender_name: invoiceData.sender ? invoiceData.sender.name : 'Unknown', - creation_date: invoiceData.creationDate || invoiceData.createdAt || invoiceData.date || 'Unknown', + invoice_id: invoiceData_.id || '', + serial_number: invoiceData_.serialNumber || invoiceData_.id || '', + sender_name: (invoiceData_.sender && invoiceData_.sender.name) || invoiceData_.senderName || '', + creation_date: invoiceData_.creationDate || invoiceData_.createdAt || invoiceData_.date || invoiceData_.operationDate || '', error_type: errorType, error_message: errorMessage, timestamp: new Date().toISOString(), @@ -596,12 +597,11 @@ ETaxes.errors = { let errorsHtml = '
'; ETaxes.loadingErrors.forEach(function(error, index) { - let documentDate = 'No date'; + let documentDate = ''; if (error.creation_date && error.creation_date !== 'Unknown') { - try { - documentDate = moment(error.creation_date).format('DD.MM.YYYY'); - } catch (e) { - documentDate = 'Invalid date'; + const m = moment(error.creation_date); + if (m.isValid()) { + documentDate = m.format('DD.MM.YYYY'); } } @@ -610,11 +610,13 @@ ETaxes.errors = { // Заголовок ошибки errorsHtml += '
'; errorsHtml += '
'; - errorsHtml += '
' + - '' + (error.serial_number || error.invoice_id) + '
'; + errorsHtml += '
' + + '' + (error.serial_number || error.invoice_id || __('Unknown')) + '
'; errorsHtml += '' + error.error_type + ''; errorsHtml += '
'; - errorsHtml += '' + error.sender_name + ''; + if (error.sender_name) { + errorsHtml += '' + error.sender_name + ''; + } errorsHtml += '
'; // Сообщение об ошибке @@ -660,9 +662,11 @@ ETaxes.errors = { errorsHtml += '
'; } - errorsHtml += '
'; - errorsHtml += '' + documentDate + ''; - errorsHtml += '
'; + if (documentDate) { + errorsHtml += '
'; + errorsHtml += '' + documentDate + ''; + errorsHtml += '
'; + } errorsHtml += ''; }); diff --git a/invoice_az/client/purchase_order_list.js b/invoice_az/client/purchase_order_list.js index d7fcae0..7c5c963 100644 --- a/invoice_az/client/purchase_order_list.js +++ b/invoice_az/client/purchase_order_list.js @@ -559,11 +559,12 @@ ETaxes.auth = { ETaxes.errors = { // Добавить ошибку в лог add: function(invoiceData, errorType, errorMessage, additionalData = {}) { + const invoiceData_ = invoiceData || {}; const errorEntry = { - invoice_id: invoiceData.id || 'Unknown', - serial_number: invoiceData.serialNumber || 'Unknown', - sender_name: invoiceData.sender ? invoiceData.sender.name : 'Unknown', - creation_date: invoiceData.creationDate || invoiceData.createdAt || invoiceData.date || 'Unknown', + invoice_id: invoiceData_.id || '', + serial_number: invoiceData_.serialNumber || invoiceData_.id || '', + sender_name: (invoiceData_.sender && invoiceData_.sender.name) || invoiceData_.senderName || '', + creation_date: invoiceData_.creationDate || invoiceData_.createdAt || invoiceData_.date || invoiceData_.operationDate || '', error_type: errorType, error_message: errorMessage, timestamp: new Date().toISOString(), @@ -598,12 +599,11 @@ ETaxes.errors = { let errorsHtml = '
'; ETaxes.loadingErrors.forEach(function(error, index) { - let documentDate = 'No date'; + let documentDate = ''; if (error.creation_date && error.creation_date !== 'Unknown') { - try { - documentDate = moment(error.creation_date).format('DD.MM.YYYY'); - } catch (e) { - documentDate = 'Invalid date'; + const m = moment(error.creation_date); + if (m.isValid()) { + documentDate = m.format('DD.MM.YYYY'); } } @@ -612,11 +612,13 @@ ETaxes.errors = { // Заголовок ошибки errorsHtml += '
'; errorsHtml += '
'; - errorsHtml += '
' + - '' + (error.serial_number || error.invoice_id) + '
'; + errorsHtml += '
' + + '' + (error.serial_number || error.invoice_id || __('Unknown')) + '
'; errorsHtml += '' + error.error_type + ''; errorsHtml += '
'; - errorsHtml += '' + error.sender_name + ''; + if (error.sender_name) { + errorsHtml += '' + error.sender_name + ''; + } errorsHtml += '
'; // Сообщение об ошибке @@ -662,10 +664,12 @@ ETaxes.errors = { errorsHtml += '
'; } - errorsHtml += '
'; - errorsHtml += '' + documentDate + ''; - errorsHtml += '
'; - + if (documentDate) { + errorsHtml += '
'; + errorsHtml += '' + documentDate + ''; + errorsHtml += '
'; + } + errorsHtml += ''; }); @@ -743,8 +747,8 @@ ETaxes.errors = { csvContent += "Invoice ID,Serial Number,Date,Supplier,Error Type,Error Message,Timestamp\n"; ETaxes.loadingErrors.forEach(function(error) { - const cleanMessage = error.error_message.replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' '); - const cleanSender = error.sender_name.replace(/"/g, '""'); + const cleanMessage = (error.error_message || '').replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' '); + const cleanSender = (error.sender_name || '').replace(/"/g, '""'); const row = [ error.invoice_id, @@ -1110,9 +1114,15 @@ ETaxes.import = { '' + __('Amount') + '' + ''; + // 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. + ETaxes.import.serialByInvoiceId = {}; + invoices.forEach(function(invoice) { const serialNumber = invoice.serialNumber || ''; - + ETaxes.import.serialByInvoiceId[String(invoice.id)] = serialNumber; + let creationDate = ''; if (invoice.createdAt) { creationDate = moment(invoice.createdAt).format('DD.MM.YYYY'); @@ -1281,7 +1291,11 @@ ETaxes.import = { if (r.message && !r.message.error) { const creationDate = r.message.creationDate || r.message.date || frappe.datetime.nowdate(); const scheduleDate = moment(creationDate).format('YYYY-MM-DD'); - const serialNumber = r.message.serialNumber || ''; + // Fall back to the serial captured from the selection list when the + // detail omits it, and write it back so the import payload (series/ + // number, title), the success alert and the error log all use it. + const serialNumber = r.message.serialNumber || (ETaxes.import.serialByInvoiceId || {})[String(invoiceId)] || ''; + r.message.serialNumber = serialNumber; const senderName = r.message.sender ? r.message.sender.name : ''; const total = r.message.totalAmount || r.message.amount || 0; @@ -1500,7 +1514,10 @@ ETaxes.import = { (data.errors || []).forEach(function(err) { ETaxes.loadingErrors.push({ - invoice_id: err.invoice_id || 'Unknown', + invoice_id: err.invoice_id || '', + // Prefer the serial the backend resolved; otherwise fall back to the + // serial captured from the selection list so we never show the raw id. + serial_number: err.serial_number || (ETaxes.import.serialByInvoiceId || {})[String(err.invoice_id)] || '', error_type: err.unmatched_items ? 'Unmapped Items' : err.unmatched_parties ? 'Unmapped Parties' : err.unmatched_units ? 'Unmapped Units' diff --git a/invoice_az/invoice_az/doctype/e_taxes_purchase/e_taxes_purchase.json b/invoice_az/invoice_az/doctype/e_taxes_purchase/e_taxes_purchase.json index 962250d..0278dff 100644 --- a/invoice_az/invoice_az/doctype/e_taxes_purchase/e_taxes_purchase.json +++ b/invoice_az/invoice_az/doctype/e_taxes_purchase/e_taxes_purchase.json @@ -8,7 +8,11 @@ "date", "total", "party", - "etaxes_id" + "etaxes_id", + "series", + "number", + "comment", + "comment2" ], "fields": [ { @@ -30,6 +34,26 @@ "fieldname": "etaxes_id", "fieldtype": "Data", "label": "etaxes_id" + }, + { + "fieldname": "series", + "fieldtype": "Data", + "label": "Series" + }, + { + "fieldname": "number", + "fieldtype": "Data", + "label": "Number" + }, + { + "fieldname": "comment", + "fieldtype": "Small Text", + "label": "Comment" + }, + { + "fieldname": "comment2", + "fieldtype": "Small Text", + "label": "Comment 2" } ], "index_web_pages_for_search": 1,