From 4adde142b43df735526fe1a95fd8905985736d7e Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Tue, 23 Jun 2026 11:43:32 +0000 Subject: [PATCH] fix(client): stop duplicate form handlers; remove tax_type grid toggle Split list-view code (frappe.listview_settings) out of the form client scripts into dedicated *_list.js files. The form files were registered under both doctype_js and doctype_list_js, so frappe.ui.form.on handlers were registered twice and form events (e.g. validate) fired twice, duplicating msgprint messages. - New: purchase_invoice_list.js, purchase_order_list.js, sales_order_list.js, sales_invoice_list.js, employee_list.js - doctype_list_js now points to the *_list.js files; journal_entry.js (list-only) removed from doctype_js - purchase_invoice.js: drop toggle_purchase_fields_visibility and the grid hidden/visible_columns hacks; tax_type is always visible and its mandatory state is driven by mandatory_depends_on. validate and tax calc now also honor agricultural_goods (equal trigger to purchase_type) Co-Authored-By: Claude Opus 4.8 (1M context) --- invoice_az/client/employee.js | 1307 --------------- invoice_az/client/employee_list.js | 1308 +++++++++++++++ invoice_az/client/purchase_invoice.js | 72 +- invoice_az/client/purchase_invoice_list.js | 31 + invoice_az/client/purchase_order.js | 76 +- invoice_az/client/purchase_order_list.js | 1628 +++++++++++++++++++ invoice_az/client/sales_invoice.js | 52 - invoice_az/client/sales_invoice_list.js | 50 + invoice_az/client/sales_order.js | 80 +- invoice_az/client/sales_order_list.js | 1703 ++++++++++++++++++++ invoice_az/hooks.py | 11 +- 11 files changed, 4742 insertions(+), 1576 deletions(-) create mode 100644 invoice_az/client/employee_list.js create mode 100644 invoice_az/client/purchase_invoice_list.js create mode 100644 invoice_az/client/purchase_order_list.js create mode 100644 invoice_az/client/sales_invoice_list.js create mode 100644 invoice_az/client/sales_order_list.js diff --git a/invoice_az/client/employee.js b/invoice_az/client/employee.js index b7a5e32..f1b6b46 100644 --- a/invoice_az/client/employee.js +++ b/invoice_az/client/employee.js @@ -272,1310 +272,3 @@ function populate_amas_integration_tab(data) { $('#amas_integration_data').html(html); } - -// Bootstrap 4.6 stacked-modal fix (twbs/bootstrap#4182): when any modal hides, -// BS4 unconditionally strips `modal-open` from , which orphans the -// backdrop of any modal still open underneath (e.g. progress + nested confirm). -// Re-apply the class if at least one modal is still visible. Idempotent: the -// flag prevents duplicate listeners if this file is re-loaded. -if (!window._amas_stacked_modal_fix_installed) { - window._amas_stacked_modal_fix_installed = true; - $(document).on('hidden.bs.modal', '.modal', function () { - if ($('.modal.show').length) { - $('body').addClass('modal-open'); - } - }); -} - -// List view — add "Load from ƏMAS" button -if (frappe.listview_settings['Employee']) { - const orig_onload = frappe.listview_settings['Employee'].onload; - frappe.listview_settings['Employee'].onload = function(listview) { - if (orig_onload) orig_onload(listview); - add_amas_load_button(listview); - reattach_amas_import_if_running(listview); - }; -} else { - frappe.listview_settings['Employee'] = { - onload: function(listview) { - add_amas_load_button(listview); - reattach_amas_import_if_running(listview); - } - }; -} - -function add_amas_load_button(listview) { - listview.page.add_inner_button(__('Load from ƏMAS'), function() { - start_amas_employee_import(listview); - }); -} - -// On listview mount, check if a worker is currently running an import for any -// of the user's connected Asan Logins. If yes, re-register the realtime -// listeners so the user can see progress + Cancel on the bar even after a -// page reload. -function reattach_amas_import_if_running(listview) { - frappe.call({ - method: 'invoice_az.amas_api.get_connected_asan_logins', - callback: function(r) { - if (!r.message || !r.message.success) return; - const asan_logins = r.message.asan_logins || []; - if (!asan_logins.length) return; - - // Probe each login until we find one with an active job. - const tryNext = function(idx) { - if (idx >= asan_logins.length) return; - const name = asan_logins[idx].name; - frappe.call({ - method: 'invoice_az.amas_api.get_amas_import_status', - args: { asan_login_name: name }, - callback: function(r2) { - if (r2.message && r2.message.running) { - attach_amas_import_listeners(listview, name, 0); - } else { - tryNext(idx + 1); - } - } - }); - }; - tryNext(0); - } - }); -} - -// Step 1: Get default connected Asan Login → fetch employees from ƏMAS API -function start_amas_employee_import(listview) { - frappe.call({ - method: 'invoice_az.amas_api.get_connected_asan_logins', - freeze: true, - freeze_message: __('Checking ƏMAS connection...'), - callback: function(r) { - if (!r.message || !r.message.success) { - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: r.message ? r.message.message : __('Failed to check ƏMAS connections') - }); - return; - } - - const asan_logins = r.message.asan_logins || []; - - if (asan_logins.length === 0) { - // No connected ƏMAS - start connection process automatically - start_amas_connection_for_import(listview); - return; - } - - // Use the first (default) connected login - fetch_employees_from_amas(listview, asan_logins[0].name, asan_logins[0].organization_name); - } - }); -} - -// Start ƏMAS connection process automatically for import -function start_amas_connection_for_import(listview) { - // Get first available Asan Login - frappe.call({ - method: 'frappe.client.get_list', - args: { - doctype: 'Asan Login', - fields: ['name', 'phone', 'user_id'], - limit_page_length: 1 - }, - callback: function(r) { - if (!r.message || r.message.length === 0) { - frappe.msgprint({ - title: __('No Asan Login Found'), - indicator: 'red', - message: __('Please create an Asan Login first.') - }); - return; - } - - const asan_login = r.message[0]; - - // Show confirmation dialog (same design as e-taxes) - const d = new frappe.ui.Dialog({ - title: __('ƏMAS Connection Required'), - fields: [ - { - fieldname: 'message', - fieldtype: 'HTML', - options: ` -
-

ƏMAS connection is required to import employees.

-

Would you like to connect now?

-
- ` - } - ], - primary_action_label: __('Connect'), - primary_action: function() { - d.hide(); - - start_mygovid_then_amas_for_employees(asan_login.name, function() { - // After successful connection, restart import - start_amas_employee_import(listview); - }, true); - }, - secondary_action_label: __('Cancel') - }); - - d.show(); - } - }); -} - -// Step 2: Fetch employees from ƏMAS API -function fetch_employees_from_amas(listview, asan_login_name, organization_name) { - frappe.show_alert({ - message: __('Fetching employees from ƏMAS...'), - indicator: 'blue' - }, 3); - - frappe.call({ - method: 'invoice_az.amas_api.get_employees_report', - args: { - asan_login_name: asan_login_name, - offset: 0, - limit: 1000 - }, - freeze: true, - freeze_message: __('Loading employees from') + ' ' + organization_name + '...', - callback: function(r) { - if (!r.message || !r.message.success) { - // Check for CSRF error - need to reconnect - if (r.message && r.message.csrf_error) { - show_amas_reconnect_dialog(asan_login_name, function() { - // Retry after successful reconnection - fetch_employees_from_amas(listview, asan_login_name, organization_name); - }); - return; - } - - if (r.message && r.message.permission_error) { - show_amas_permission_error_dialog( - asan_login_name, - r.message.message, - function() { - fetch_employees_from_amas(listview, asan_login_name, organization_name); - } - ); - return; - } - - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: r.message ? r.message.message : __('Failed to load employees from ƏMAS') - }); - return; - } - - const employees = r.message.employees || []; - - if (employees.length === 0) { - frappe.msgprint({ - title: __('Information'), - indicator: 'blue', - message: __('No employees found in ƏMAS for this organization.') - }); - return; - } - - frappe.show_alert({ - message: __('Processing ') + employees.length + __(' employees...'), - indicator: 'blue' - }, 3); - - check_existing_and_show(listview, asan_login_name, organization_name, employees); - } - }); -} - -// Step 3: Check existing employees and show selection dialog -function check_existing_and_show(listview, asan_login_name, organization_name, employees) { - const fins = employees - .map(e => e.identification_number) - .filter(f => f); - - frappe.call({ - method: 'frappe.client.get_list', - args: { - doctype: 'Employee', - filters: { passport_number: ['in', fins] }, - fields: ['name', 'passport_number', 'employee_name'], - limit_page_length: 0 - }, - callback: function(r) { - const existing_map = {}; - if (r.message) { - r.message.forEach(function(emp) { - if (emp.passport_number) { - existing_map[emp.passport_number] = emp.name; - } - }); - } - - show_employee_selection(listview, asan_login_name, organization_name, employees, existing_map); - } - }); -} - -// Step 4: Show selection dialog (e-taxes style) -function show_employee_selection(listview, asan_login_name, organization_name, employees, existing_map) { - const new_count = employees.filter(e => !existing_map[e.identification_number]).length; - const existing_count = employees.length - new_count; - - // Build table (e-taxes style) - // Таблица сотрудников — единственная прокручиваемая область. Высота привязана - // к вьюпорту (calc), чтобы модал целиком помещался в экран. Шапка «липкая». - let invoiceTable = '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - employees.forEach(function(emp, idx) { - const fin = emp.identification_number || ''; - const existing_id = existing_map[fin]; - const status_cell = existing_id - ? '' + frappe.utils.escape_html(existing_id) + '' - : ''; - - invoiceTable += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - - invoiceTable += '
' + __('Full Name') + '' + __('FIN') + '' + __('Position') + '' + __('Salary') + '' + __('Status') + '' + __('Begin Date') + '' + __('In System') + '
' + frappe.utils.escape_html(emp.full_name || '') + '' + frappe.utils.escape_html(fin) + '' + frappe.utils.escape_html(emp.work_position_text || '') + '' + frappe.utils.escape_html(emp.monthly_salary || '') + '' + frappe.utils.escape_html(emp.contract_status || '') + '' + frappe.utils.escape_html(emp.begin_date || '') + '' + status_cell + '
'; - - // Компактная шапка: счётчик сотрудников слева + организация справа в одну - // строку. Заменяет alert со счётчиком и убирает пустые Section Break-лейблы. - const headerHtml = - '
' + - '' + - __('Employees found: ') + '' + employees.length + '' + - ' (' + __('New') + ': ' + new_count + ', ' + __('Already in system') + ': ' + existing_count + ')' + - '' + - (organization_name - ? '' + - __('Organization: ') + '' + frappe.utils.escape_html(organization_name) + '' + - '' - : '') + - '
'; - - const default_company = frappe.defaults.get_user_default('Company') || frappe.defaults.get_global_default('company'); - - const d = new frappe.ui.Dialog({ - title: __('Select employees from ƏMAS'), - size: 'large', - fields: [ - { - fieldname: 'header_html', - fieldtype: 'HTML', - options: headerHtml - }, - { - fieldname: 'create_designation', - fieldtype: 'Check', - label: __('Create Designation if not found'), - default: 1 - }, - { - fieldname: 'employees_html', - fieldtype: 'HTML', - options: invoiceTable - } - ], - primary_action_label: __('Load selected'), - primary_action: function(values) { - const selected_employees = []; - d.$wrapper.find('.select-employee:checked').each(function() { - const idx = $(this).data('index'); - selected_employees.push(employees[idx]); - }); - - if (selected_employees.length === 0) { - frappe.msgprint({ - title: __('Warning'), - indicator: 'orange', - message: __('No employees selected') - }); - return; - } - - d.hide(); - create_employees(listview, asan_login_name, selected_employees, default_company, values.create_designation); - }, - secondary_action_label: __('Cancel'), - secondary_action: function() { d.hide(); } - }); - - // Match e-taxes dialog sizing - d.$wrapper.find('.modal-dialog').css({ - 'max-width': '80%', - 'width': '80%', - 'margin': '30px auto' - }); - - d.$wrapper.find('.modal-body').css({ - 'padding': '15px' - }); - - d.show(); - - // Select all checkbox - d.$wrapper.find('.select-all-employees').on('change', function() { - const isChecked = $(this).prop('checked'); - d.$wrapper.find('.select-employee').prop('checked', isChecked); - }); -} - -// Step 5: Create employees via backend - Socket.IO bulk import -// Wire realtime listeners for an in-flight ƏMAS import. Used both when the -// user starts a new import and when reattaching to an already-running one -// after a page reload. -function attach_amas_import_listeners(listview, asan_login_name, total_hint) { - const total = total_hint || 0; - - // Reused single confirm dialog. frappe.confirm creates a fresh dialog on - // each call and never removes the old element from DOM, which causes - // Bootstrap's modal-backdrop stack to glitch on repeat use. - let cancel_confirm_dialog = null; - - const ensure_cancel_button = function(dialog) { - if (!dialog || dialog._amas_cancel_attached) return; - dialog._amas_cancel_attached = true; - dialog.set_primary_action(__('Cancel Import'), function() { - if (!cancel_confirm_dialog) { - cancel_confirm_dialog = new frappe.ui.Dialog({ - title: __('Confirm'), - primary_action_label: __('Yes, cancel'), - primary_action: function() { - cancel_confirm_dialog.hide(); - frappe.call({ - method: 'invoice_az.amas_api.cancel_amas_import', - args: { asan_login_name: asan_login_name }, - callback: function() { - frappe.show_alert({ - message: __('Cancellation requested. Finishing current employee...'), - indicator: 'orange' - }, 5); - } - }); - }, - secondary_action_label: __('No, keep importing'), - secondary_action: function() { - cancel_confirm_dialog.hide(); - } - }); - cancel_confirm_dialog.$body.append( - '

' + __('Cancel the running ƏMAS import? Already-saved employees will stay; the rest will be skipped.') + '

' - ); - } - cancel_confirm_dialog.show(); - // Progress dialog forces z-index 2000; lift the confirm above it. - cancel_confirm_dialog.$wrapper.css('z-index', 2100); - }); - }; - - // Hand the reusable confirm dialog over to the import-complete handler so - // it can fully remove the leftover element from DOM when the import ends. - const cleanup_cancel_dialog = function() { - if (cancel_confirm_dialog) { - cancel_confirm_dialog.hide(); - cancel_confirm_dialog.$wrapper.remove(); - cancel_confirm_dialog = null; - } - }; - - // Защита от гонки realtime: 'complete' вызывает hide_progress() (обнуляет - // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который - // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. - let importFinished = false; - // True once we've observed the import actually running (via a realtime tick - // or a status poll). Guards the poll-based completion detector so it can't - // fire before the worker has even started (the running flag is set - // server-side a beat after import_bulk_employees is called). - let sawRunning = (total > 0); - // Highest overall percent (0-100) rendered so far. Realtime and polling both - // feed the bar; this keeps it monotonic so a stale poll can't rewind a - // fresher realtime tick (or vice-versa). - let lastPercent = 0; - - let dialog = frappe.show_progress( - __('Importing from ƏMAS'), 0, 100, - total - ? __('Starting import of {0} employees...', [total]) - : __('Preparing import...') - ); - ensure_cancel_button(dialog); - - const phaseLabel = function(phase) { - if (phase === 'save') return __('Saving employees'); - if (phase === 'fetch') return __('Fetching details'); - if (phase === 'connecting') return __('Connecting to ƏMAS...'); - if (phase === 'queued') return __('Waiting to start...'); - return __('Preparing import...'); - }; - - // The bar tracks ONE overall percent across both import phases (fetch = - // 0-50, save = 50-100), computed server-side. Fall back to deriving it the - // same way if an older worker sends no `percent`. - const overallPercent = function(percent, current, total, phase) { - if (typeof percent === 'number' && percent >= 0) return percent; - if (!(total > 0)) return 0; - const frac = Math.max(0, Math.min(1, current / total)); - if (phase === 'save') return 50 + frac * 50; - if (phase === 'fetch') return frac * 50; - return 0; - }; - - // Single entry point for moving the bar, fed by both the realtime handler - // and the status poll. Never regresses. - const renderProgress = function(percent, current, total, phase, employee_name) { - if (importFinished) return; - const pct = overallPercent(percent, current, total, phase); - if (pct > lastPercent) lastPercent = pct; - - const label = phaseLabel(phase); - let detail; - if (phase === 'queued' || phase === 'connecting' || !(total > 0)) { - detail = label; - } else if (employee_name) { - detail = label + ': ' + employee_name + ' (' + current + '/' + total + ')'; - } else { - detail = label + ': ' + __('{0} of {1}', [current, total]); - } - const d = frappe.show_progress( - __('Importing from ƏMAS'), Math.round(lastPercent), 100, detail - ); - ensure_cancel_button(d); - }; - - frappe.realtime.off('amas_import_progress'); - frappe.realtime.on('amas_import_progress', function(data) { - if (importFinished) return; - sawRunning = true; - renderProgress(data.percent, data.current, data.total, data.phase, data.employee_name); - }); - - // Polling fallback. Realtime events are transient — anything published - // before this page subscribed (after the wizard redirects to /app, or a - // reload) is lost, leaving the bar frozen. Polling the server-persisted - // progress guarantees the bar moves, and lets us detect completion even - // when the 'amas_import_complete' event itself was missed. - const finishFromPoll = function() { - if (importFinished) return; - importFinished = true; - frappe.realtime.off('amas_import_progress'); - frappe.realtime.off('amas_import_complete'); - closeAmasProgress(); // also clears amasPollTimer - cleanup_cancel_dialog(); - listview.refresh(); - frappe.show_alert({ message: __('ƏMAS import finished.'), indicator: 'green' }, 5); - }; - - const pollStatus = function() { - if (importFinished) return; - frappe.call({ - method: 'invoice_az.amas_api.get_amas_import_status', - args: { asan_login_name: asan_login_name }, - callback: function(r) { - if (importFinished) return; - const m = r.message || {}; - if (m.running) { - sawRunning = true; - renderProgress(m.percent, m.current, m.total, m.phase, m.employee_name); - } else if (sawRunning) { - // We watched it run and now it's gone — the realtime - // 'complete' was missed. Wrap up gracefully. - finishFromPoll(); - } - } - }); - }; - - if (amasPollTimer) clearInterval(amasPollTimer); - amasPollTimer = setInterval(pollStatus, 1000); - // Reconnect path (no total hint): the caller already confirmed an import is - // running, so seed the bar from real numbers immediately instead of waiting - // a full poll interval on the bare "preparing" text. - if (!total) pollStatus(); - - frappe.realtime.off('amas_import_complete'); - frappe.realtime.on('amas_import_complete', function(data) { - if (importFinished) return; // poll-based finish already wrapped up - importFinished = true; - frappe.realtime.off('amas_import_progress'); - frappe.realtime.off('amas_import_complete'); - closeAmasProgress(); - cleanup_cancel_dialog(); - - if (data.cancelled) { - frappe.msgprint({ - title: __('Import Cancelled'), - indicator: 'orange', - message: __( - 'Cancelled. Created: {0}, updated: {1}.', - [data.created || 0, data.updated || 0] - ) - }); - listview.refresh(); - return; - } - - if (data.aborted) { - const reason = (data.errors && data.errors[0] && data.errors[0].error) - || __('Import was aborted.'); - frappe.msgprint({ - title: __('Import Aborted'), - indicator: 'red', - message: reason - }); - listview.refresh(); - return; - } - - const error_messages = (data.errors || []).map(function(err) { - return (err.employee || 'Unknown') + ': ' + (err.error || 'Unknown error'); - }); - - const total_processed = data.created + data.updated + (data.errors || []).length; - show_import_summary(total_processed, data.created, data.updated, (data.errors || []).length, error_messages); - - listview.refresh(); - }); -} - -// Module-level handle for the status-poll interval (fallback that drives the -// progress bar when transient realtime events are missed). Only one ƏMAS import -// is ever tracked at a time, so a single timer is enough. closeAmasProgress() -// is the universal teardown path, so clearing it there covers every exit. -let amasPollTimer = null; - -// Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении -// (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно. -function closeAmasProgress() { - if (amasPollTimer) { - clearInterval(amasPollTimer); - amasPollTimer = null; - } - if (frappe.cur_progress) { - try { - frappe.cur_progress.$wrapper.modal('hide'); - frappe.cur_progress.$wrapper.remove(); - } catch (e) {} - frappe.cur_progress = null; - } - if ($('.modal:visible').length === 0) { - $('.modal-backdrop').remove(); - $('body').removeClass('modal-open'); - } -} - -function create_employees(listview, asan_login_name, selected_employees, company, create_designation) { - const total = selected_employees.length; - - attach_amas_import_listeners(listview, asan_login_name, total); - - frappe.call({ - method: 'invoice_az.amas_api.import_bulk_employees', - args: { - asan_login_name: asan_login_name, - employees_data: JSON.stringify(selected_employees), - company: company, - create_designation: create_designation ? 1 : 0 - }, - callback: function(r) { - if (r.message && r.message.already_running) { - frappe.realtime.off('amas_import_progress'); - frappe.realtime.off('amas_import_complete'); - closeAmasProgress(); - frappe.msgprint({ - title: __('Import Already Running'), - indicator: 'orange', - message: r.message.message - }); - return; - } - if (!r.message || !r.message.enqueued) { - frappe.realtime.off('amas_import_progress'); - frappe.realtime.off('amas_import_complete'); - closeAmasProgress(); - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: __('Failed to start import job') - }); - } - }, - error: function() { - frappe.realtime.off('amas_import_progress'); - frappe.realtime.off('amas_import_complete'); - closeAmasProgress(); - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: __('Network error starting import') - }); - } - }); -} - -// Process employees one by one (like E-Taxes loadSelectedInvoices) -function process_employees_one_by_one(listview, asan_login_name, employees_queue, company, create_designation, - created_count, updated_count, error_count, errors, current_index) { - // Check if finished - if (employees_queue.length === 0) { - closeAmasProgress(); - $(document).off('progress-cancel.loading_amas_employees'); - - const total = created_count + updated_count + error_count; - show_import_summary(total, created_count, updated_count, error_count, errors); - - window.amas_employees_to_process = null; - listview.refresh(); - return; - } - - // Check if cancelled - if (window.amas_cancel_loading) { - employees_queue = []; - closeAmasProgress(); - $(document).off('progress-cancel.loading_amas_employees'); - - const total = created_count + updated_count + error_count; - frappe.show_alert({ - message: __('Import cancelled. Processed ') + total + __(' employees.'), - indicator: 'orange' - }, 5); - - listview.refresh(); - return; - } - - // Take first employee from queue - const emp_data = employees_queue.shift(); - const is_last = employees_queue.length === 0; - const total_to_process = window.amas_employees_to_process || (created_count + updated_count + error_count + employees_queue.length + 1); - - current_index++; - - // Show progress - frappe.show_progress( - __('Loading from ƏMAS'), - current_index - 1, - total_to_process, - __('Processing employee ') + current_index + __(' of ') + total_to_process, - null, - true - ); - - // Setup cancel handler - $(document).off('progress-cancel.loading_amas_employees'); - $(document).on('progress-cancel.loading_amas_employees', function() { - window.amas_cancel_loading = true; - frappe.show_alert({ - message: __('Cancelling import... Finishing current employee.'), - indicator: 'orange' - }, 3); - closeAmasProgress(); - }); - - // Get employee name for progress message - const emp_name = emp_data.full_name || emp_data.identification_number || 'Employee'; - - // Update progress with employee name - frappe.show_progress( - __('Loading from ƏMAS'), - current_index - 1, - total_to_process, - __('Processing: ') + emp_name - ); - - // Create/update single employee - frappe.call({ - method: 'invoice_az.amas_api.create_single_employee_from_amas', - args: { - asan_login_name: asan_login_name, - emp_data: emp_data, - company: company, - create_designation: create_designation ? 1 : 0 - }, - callback: function(r) { - if (r.message && r.message.success) { - // Success - increment counters - if (r.message.action === 'created') { - created_count++; - frappe.show_alert({ - message: __('Created: ') + r.message.full_name, - indicator: 'green' - }, 2); - } else if (r.message.action === 'updated') { - updated_count++; - frappe.show_alert({ - message: __('Updated: ') + r.message.full_name, - indicator: 'blue' - }, 2); - } - } else { - // Error - error_count++; - const error_msg = r.message ? r.message.message : 'Unknown error'; - errors.push(error_msg); - - frappe.show_alert({ - message: __('Error: ') + (r.message ? r.message.full_name : emp_name), - indicator: 'red' - }, 3); - } - - // Continue with next employee after short delay (like E-Taxes) - setTimeout(function() { - process_employees_one_by_one( - listview, - asan_login_name, - employees_queue, - company, - create_designation, - created_count, - updated_count, - error_count, - errors, - current_index - ); - }, 50); // 50ms delay like E-Taxes PROGRESS_UPDATE_DELAY - }, - error: function(xhr, status, error) { - // Network error - error_count++; - const error_msg = emp_name + ': Network error - ' + (error || 'Unknown error'); - errors.push(error_msg); - - frappe.show_alert({ - message: __('Network error: ') + emp_name, - indicator: 'red' - }, 3); - - // Continue with next employee - setTimeout(function() { - process_employees_one_by_one( - listview, - asan_login_name, - employees_queue, - company, - create_designation, - created_count, - updated_count, - error_count, - errors, - current_index - ); - }, 50); - } - }); -} - -// Show final summary (like E-Taxes) -function show_import_summary(total, created_count, updated_count, error_count, errors) { - if (error_count === 0) { - let msg = __('All ') + total + __(' employees were processed successfully.'); - if (created_count > 0) { - msg += '
' + __('Created: ') + '' + created_count + ''; - } - if (updated_count > 0) { - msg += '
' + __('Updated: ') + '' + updated_count + ''; - } - - frappe.msgprint({ - title: __('Import Completed Successfully'), - indicator: 'green', - message: msg - }); - } else { - let msg = '
'; - if (created_count > 0) { - msg += __('Successfully created: ') + '' + created_count + '
'; - } - if (updated_count > 0) { - msg += __('Updated: ') + '' + updated_count + '
'; - } - msg += __('Failed: ') + '' + error_count + ''; - msg += '
'; - - if (errors && errors.length > 0) { - msg += '
'; - msg += '
'; - } - - frappe.msgprint({ - title: created_count > 0 ? __('Import Completed with Errors') : __('Import Failed'), - indicator: created_count > 0 ? 'orange' : 'red', - message: msg - }); - } -} - -// Show a confirm dialog for ƏMAS permission errors with a Reconnect action. -// On confirm: runs full MyGovID + ƏMAS reauth and shows the organization -// selection dialog so the user can pick a different certificate. -function show_amas_permission_error_dialog(asan_login_name, message, retry_callback) { - const body = (message || __("The selected ƏMAS certificate doesn't have permission for this operation.")) - + '

' - + __('Would you like to reconnect to ƏMAS and choose a different organization?'); - - frappe.confirm( - body, - function() { - start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, true); - } - ); -} - -// Show reconnection dialog for ƏMAS when token expires (CSRF error) -function show_amas_reconnect_dialog(asan_login_name, retry_callback) { - const d = new frappe.ui.Dialog({ - title: __('ƏMAS Authentication Required'), - fields: [ - { - fieldname: 'message', - fieldtype: 'HTML', - options: ` -
-

- -

-

Your ƏMAS session has expired or authentication is required.

-

Would you like to reconnect to ƏMAS?

-
- ` - } - ], - primary_action_label: __('Reconnect'), - primary_action: function() { - d.hide(); - - // Try to reconnect to ƏMAS - frappe.call({ - method: 'invoice_az.amas_api.connect_amas', - args: { - 'asan_login_name': asan_login_name - }, - freeze: true, - freeze_message: __('Reconnecting to ƏMAS...'), - callback: function(r) { - if (r.message && r.message.success) { - // Check if we need to select organization - const accounts = r.message.accounts || []; - - if (accounts.length > 0) { - // Show organization selection dialog - show_amas_accounts_dialog(asan_login_name, accounts, retry_callback); - } else { - // No accounts to select, just proceed - frappe.show_alert({ - message: __('ƏMAS reconnected successfully!'), - indicator: 'green' - }, 3); - - // Retry the original operation - if (typeof retry_callback === 'function') { - setTimeout(retry_callback, 500); - } - } - } else if (r.message && r.message.need_mygovid_login) { - // MyGovID token expired - start full authentication flow with organization selection - start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, true); - } else { - frappe.msgprint({ - title: __('Reconnection Failed'), - indicator: 'red', - message: r.message ? r.message.message : __('Failed to reconnect to ƏMAS.') - }); - } - } - }); - }, - secondary_action_label: __('Cancel') - }); - - d.show(); -} - -// Start MyGovID authentication followed by ƏMAS connection (for employees) -// show_org_selection: true = show organization selection dialog, false = auto-select first org -function start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, show_org_selection) { - // Default to false (auto-select) for backward compatibility - if (show_org_selection === undefined) { - show_org_selection = false; - } - frappe.show_alert({ - message: __('Starting MyGovID authentication...'), - indicator: 'blue' - }, 3); - - // Create status dialog - const status_dialog = new frappe.ui.Dialog({ - title: __('MyGovID Authentication for ƏMAS'), - fields: [ - { - fieldname: 'status_html', - fieldtype: 'HTML', - options: ` -
-
-
-
- -
-

Waiting for confirmation on your phone

-

Please check your phone and confirm the authentication request in ASAN Sign app.

-
- -
- ` - } - ], - primary_action_label: __('Cancel'), - primary_action: function() { - window.mygovidStop = true; - status_dialog.hide(); - } - }); - - status_dialog.show(); - window.mygovidStop = false; - - // Start MyGovID login - frappe.call({ - method: 'invoice_az.invoice_az.doctype.asan_login.asan_login.start_mygovid_login', - args: { - 'asan_login_name': asan_login_name - }, - timeout: 130000, - callback: function(r) { - if (window.mygovidStop) return; - - if (r.message && r.message.success) { - // MyGovID login successful, now connect to ƏMAS - $('#mygovid_status').html(` -

Connecting to ƏMAS...

-

MyGovID authentication successful. Now connecting to ƏMAS...

- `); - $('#mygovid_code').hide(); - - // Connect to ƏMAS - frappe.call({ - method: 'invoice_az.amas_api.connect_amas', - args: { - 'asan_login_name': asan_login_name - }, - callback: function(r2) { - if (r2.message && r2.message.success) { - const accounts = r2.message.accounts || []; - - if (accounts.length > 0) { - // Check if we should show selection dialog or auto-select - if (show_org_selection) { - // Show organization selection dialog (for re-authentication) - status_dialog.hide(); - show_amas_accounts_dialog(asan_login_name, accounts, retry_callback); - } else { - // Auto-select first organization (for initial connection) - const first_account = accounts[0]; - const oid = first_account.accountOid || first_account.oid || ''; - const name = first_account.accountName || first_account.name || 'Unknown'; - const number = first_account.accountNumber || first_account.voen || first_account.number || ''; - const role = first_account.roleName || first_account.role || 'chairman'; - - $('#mygovid_status').html(` -

Selecting organization...

-

ƏMAS connected. Selecting ${frappe.utils.escape_html(name)}...

- `); - - // Auto-select first account - frappe.call({ - method: 'invoice_az.amas_api.change_amas_account', - args: { - 'asan_login_name': asan_login_name, - 'account_oid': oid, - 'account_name': name, - 'account_number': number, - 'role_name': role - }, - callback: function(r3) { - if (r3.message && r3.message.success) { - $('#mygovid_status').html(` -

ƏMAS Connected!

-

Successfully connected to ${frappe.utils.escape_html(name)}.

- `); - $('.mygovid-spinner').html(` -
- -
- `); - - setTimeout(function() { - status_dialog.hide(); - if (typeof retry_callback === 'function') { - retry_callback(); - } - }, 2000); - } else { - $('#mygovid_status').html(` -

Organization Selection Failed

-

${r3.message ? r3.message.message : 'Failed to select organization'}

- `); - $('.mygovid-spinner').css('display', 'none'); - - status_dialog.set_primary_action(__('Close'), function() { - status_dialog.hide(); - }); - } - } - }); - } - } else { - // No accounts - just complete - $('#mygovid_status').html(` -

ƏMAS Connected!

-

Successfully connected to ƏMAS.

- `); - $('.mygovid-spinner').html(` -
- -
- `); - - status_dialog.set_primary_action(__('Continue'), function() { - status_dialog.hide(); - if (typeof retry_callback === 'function') { - setTimeout(retry_callback, 500); - } - }); - - setTimeout(function() { - status_dialog.hide(); - if (typeof retry_callback === 'function') { - retry_callback(); - } - }, 2000); - } - } else { - $('#mygovid_status').html(` -

ƏMAS Connection Failed

-

${r2.message ? r2.message.message : 'Unknown error'}

- `); - $('.mygovid-spinner').css('display', 'none'); - - status_dialog.set_primary_action(__('Close'), function() { - status_dialog.hide(); - }); - } - } - }); - } else { - // MyGovID login failed - $('#mygovid_status').html(` -

Authentication Failed

-

${r.message ? r.message.message : 'Unknown error'}

- `); - $('.mygovid-spinner').css('display', 'none'); - $('#mygovid_code').hide(); - - status_dialog.set_primary_action(__('Close'), function() { - status_dialog.hide(); - }); - } - }, - error: function() { - if (window.mygovidStop) return; - - $('#mygovid_status').html(` -

Authentication Timeout

-

The request timed out. Please try again.

- `); - $('.mygovid-spinner').css('display', 'none'); - $('#mygovid_code').hide(); - - status_dialog.set_primary_action(__('Close'), function() { - status_dialog.hide(); - }); - } - }); - - // Get verification code - setTimeout(function() { - frappe.call({ - method: 'invoice_az.invoice_az.doctype.asan_login.asan_login.get_mygovid_verification_code', - args: { 'asan_login_name': asan_login_name }, - callback: function(r) { - if (r.message && r.message.success) { - $('#mygovid_code_text').text(r.message.code); - $('#mygovid_code').show(); - } - } - }); - }, 500); -} - -// Show ƏMAS accounts selection dialog -function show_amas_accounts_dialog(asan_login_name, accounts, retry_callback) { - if (!accounts || accounts.length === 0) { - frappe.msgprint(__('No organizations found in ƏMAS')); - return; - } - - // Build accounts table - let table_html = '
'; - table_html += ''; - table_html += ''; - table_html += ''; - - accounts.forEach(function(account, index) { - const name = account.accountName || account.name || 'Unknown'; - const voen = account.accountNumber || account.voen || account.number || ''; - const role = account.roleName || account.role || ''; - const oid = account.accountOid || account.oid || ''; - - table_html += ` - - - - `; - }); - - table_html += '
Organization NameVOENAction
${frappe.utils.escape_html(name)}${frappe.utils.escape_html(voen)} - -
'; - - const d = new frappe.ui.Dialog({ - title: __('Select ƏMAS Organization'), - fields: [ - { - fieldname: 'info', - fieldtype: 'HTML', - options: '
Note: Please select the organization you want to work with.
' - }, - { - fieldname: 'accounts_html', - fieldtype: 'HTML', - options: table_html - } - ], - primary_action_label: __('Cancel'), - primary_action: function() { - d.hide(); - } - }); - - // Make dialog wider - d.$wrapper.find('.modal-dialog').css({ - 'max-width': '70%', - 'width': '70%' - }); - - d.show(); - - // Add click handler for account selection - d.$wrapper.find('.select-amas-account').on('click', function() { - const oid = $(this).data('oid'); - const name = $(this).data('name'); - const number = $(this).data('number'); - const role = $(this).data('role'); - - // Disable all buttons - d.$wrapper.find('.select-amas-account').prop('disabled', true); - $(this).html(' Selecting...'); - - // Call backend to select account - frappe.call({ - method: 'invoice_az.amas_api.change_amas_account', - args: { - 'asan_login_name': asan_login_name, - 'account_oid': oid, - 'account_name': name, - 'account_number': number, - 'role_name': role || 'chairman' - }, - callback: function(r) { - if (r.message && r.message.success) { - d.hide(); - frappe.show_alert({ - message: __('Organization selected: ') + name, - indicator: 'green' - }, 3); - - // Retry the original operation after short delay - if (typeof retry_callback === 'function') { - setTimeout(retry_callback, 500); - } - } else { - // Re-enable buttons on error - d.$wrapper.find('.select-amas-account').prop('disabled', false); - d.$wrapper.find('.select-amas-account').each(function() { - if ($(this).find('.fa-spinner').length) { - $(this).html('Select'); - } - }); - - if (r.message && r.message.permission_error) { - d.hide(); - show_amas_permission_error_dialog( - asan_login_name, - r.message.message, - retry_callback - ); - return; - } - - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: r.message ? r.message.message : __('Failed to select organization') - }); - } - } - }); - }); -} diff --git a/invoice_az/client/employee_list.js b/invoice_az/client/employee_list.js new file mode 100644 index 0000000..3d651c6 --- /dev/null +++ b/invoice_az/client/employee_list.js @@ -0,0 +1,1308 @@ +// List view settings for Employee. Loaded only via doctype_list_js. Do NOT add frappe.ui.form.on(...) here. + +// Bootstrap 4.6 stacked-modal fix (twbs/bootstrap#4182): when any modal hides, +// BS4 unconditionally strips `modal-open` from , which orphans the +// backdrop of any modal still open underneath (e.g. progress + nested confirm). +// Re-apply the class if at least one modal is still visible. Idempotent: the +// flag prevents duplicate listeners if this file is re-loaded. +if (!window._amas_stacked_modal_fix_installed) { + window._amas_stacked_modal_fix_installed = true; + $(document).on('hidden.bs.modal', '.modal', function () { + if ($('.modal.show').length) { + $('body').addClass('modal-open'); + } + }); +} + +// List view — add "Load from ƏMAS" button +if (frappe.listview_settings['Employee']) { + const orig_onload = frappe.listview_settings['Employee'].onload; + frappe.listview_settings['Employee'].onload = function(listview) { + if (orig_onload) orig_onload(listview); + add_amas_load_button(listview); + reattach_amas_import_if_running(listview); + }; +} else { + frappe.listview_settings['Employee'] = { + onload: function(listview) { + add_amas_load_button(listview); + reattach_amas_import_if_running(listview); + } + }; +} + +function add_amas_load_button(listview) { + listview.page.add_inner_button(__('Load from ƏMAS'), function() { + start_amas_employee_import(listview); + }); +} + +// On listview mount, check if a worker is currently running an import for any +// of the user's connected Asan Logins. If yes, re-register the realtime +// listeners so the user can see progress + Cancel on the bar even after a +// page reload. +function reattach_amas_import_if_running(listview) { + frappe.call({ + method: 'invoice_az.amas_api.get_connected_asan_logins', + callback: function(r) { + if (!r.message || !r.message.success) return; + const asan_logins = r.message.asan_logins || []; + if (!asan_logins.length) return; + + // Probe each login until we find one with an active job. + const tryNext = function(idx) { + if (idx >= asan_logins.length) return; + const name = asan_logins[idx].name; + frappe.call({ + method: 'invoice_az.amas_api.get_amas_import_status', + args: { asan_login_name: name }, + callback: function(r2) { + if (r2.message && r2.message.running) { + attach_amas_import_listeners(listview, name, 0); + } else { + tryNext(idx + 1); + } + } + }); + }; + tryNext(0); + } + }); +} + +// Step 1: Get default connected Asan Login → fetch employees from ƏMAS API +function start_amas_employee_import(listview) { + frappe.call({ + method: 'invoice_az.amas_api.get_connected_asan_logins', + freeze: true, + freeze_message: __('Checking ƏMAS connection...'), + callback: function(r) { + if (!r.message || !r.message.success) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Failed to check ƏMAS connections') + }); + return; + } + + const asan_logins = r.message.asan_logins || []; + + if (asan_logins.length === 0) { + // No connected ƏMAS - start connection process automatically + start_amas_connection_for_import(listview); + return; + } + + // Use the first (default) connected login + fetch_employees_from_amas(listview, asan_logins[0].name, asan_logins[0].organization_name); + } + }); +} + +// Start ƏMAS connection process automatically for import +function start_amas_connection_for_import(listview) { + // Get first available Asan Login + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'Asan Login', + fields: ['name', 'phone', 'user_id'], + limit_page_length: 1 + }, + callback: function(r) { + if (!r.message || r.message.length === 0) { + frappe.msgprint({ + title: __('No Asan Login Found'), + indicator: 'red', + message: __('Please create an Asan Login first.') + }); + return; + } + + const asan_login = r.message[0]; + + // Show confirmation dialog (same design as e-taxes) + const d = new frappe.ui.Dialog({ + title: __('ƏMAS Connection Required'), + fields: [ + { + fieldname: 'message', + fieldtype: 'HTML', + options: ` +
+

ƏMAS connection is required to import employees.

+

Would you like to connect now?

+
+ ` + } + ], + primary_action_label: __('Connect'), + primary_action: function() { + d.hide(); + + start_mygovid_then_amas_for_employees(asan_login.name, function() { + // After successful connection, restart import + start_amas_employee_import(listview); + }, true); + }, + secondary_action_label: __('Cancel') + }); + + d.show(); + } + }); +} + +// Step 2: Fetch employees from ƏMAS API +function fetch_employees_from_amas(listview, asan_login_name, organization_name) { + frappe.show_alert({ + message: __('Fetching employees from ƏMAS...'), + indicator: 'blue' + }, 3); + + frappe.call({ + method: 'invoice_az.amas_api.get_employees_report', + args: { + asan_login_name: asan_login_name, + offset: 0, + limit: 1000 + }, + freeze: true, + freeze_message: __('Loading employees from') + ' ' + organization_name + '...', + callback: function(r) { + if (!r.message || !r.message.success) { + // Check for CSRF error - need to reconnect + if (r.message && r.message.csrf_error) { + show_amas_reconnect_dialog(asan_login_name, function() { + // Retry after successful reconnection + fetch_employees_from_amas(listview, asan_login_name, organization_name); + }); + return; + } + + if (r.message && r.message.permission_error) { + show_amas_permission_error_dialog( + asan_login_name, + r.message.message, + function() { + fetch_employees_from_amas(listview, asan_login_name, organization_name); + } + ); + return; + } + + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Failed to load employees from ƏMAS') + }); + return; + } + + const employees = r.message.employees || []; + + if (employees.length === 0) { + frappe.msgprint({ + title: __('Information'), + indicator: 'blue', + message: __('No employees found in ƏMAS for this organization.') + }); + return; + } + + frappe.show_alert({ + message: __('Processing ') + employees.length + __(' employees...'), + indicator: 'blue' + }, 3); + + check_existing_and_show(listview, asan_login_name, organization_name, employees); + } + }); +} + +// Step 3: Check existing employees and show selection dialog +function check_existing_and_show(listview, asan_login_name, organization_name, employees) { + const fins = employees + .map(e => e.identification_number) + .filter(f => f); + + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'Employee', + filters: { passport_number: ['in', fins] }, + fields: ['name', 'passport_number', 'employee_name'], + limit_page_length: 0 + }, + callback: function(r) { + const existing_map = {}; + if (r.message) { + r.message.forEach(function(emp) { + if (emp.passport_number) { + existing_map[emp.passport_number] = emp.name; + } + }); + } + + show_employee_selection(listview, asan_login_name, organization_name, employees, existing_map); + } + }); +} + +// Step 4: Show selection dialog (e-taxes style) +function show_employee_selection(listview, asan_login_name, organization_name, employees, existing_map) { + const new_count = employees.filter(e => !existing_map[e.identification_number]).length; + const existing_count = employees.length - new_count; + + // Build table (e-taxes style) + // Таблица сотрудников — единственная прокручиваемая область. Высота привязана + // к вьюпорту (calc), чтобы модал целиком помещался в экран. Шапка «липкая». + let invoiceTable = '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + employees.forEach(function(emp, idx) { + const fin = emp.identification_number || ''; + const existing_id = existing_map[fin]; + const status_cell = existing_id + ? '' + frappe.utils.escape_html(existing_id) + '' + : ''; + + invoiceTable += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + invoiceTable += '
' + __('Full Name') + '' + __('FIN') + '' + __('Position') + '' + __('Salary') + '' + __('Status') + '' + __('Begin Date') + '' + __('In System') + '
' + frappe.utils.escape_html(emp.full_name || '') + '' + frappe.utils.escape_html(fin) + '' + frappe.utils.escape_html(emp.work_position_text || '') + '' + frappe.utils.escape_html(emp.monthly_salary || '') + '' + frappe.utils.escape_html(emp.contract_status || '') + '' + frappe.utils.escape_html(emp.begin_date || '') + '' + status_cell + '
'; + + // Компактная шапка: счётчик сотрудников слева + организация справа в одну + // строку. Заменяет alert со счётчиком и убирает пустые Section Break-лейблы. + const headerHtml = + '
' + + '' + + __('Employees found: ') + '' + employees.length + '' + + ' (' + __('New') + ': ' + new_count + ', ' + __('Already in system') + ': ' + existing_count + ')' + + '' + + (organization_name + ? '' + + __('Organization: ') + '' + frappe.utils.escape_html(organization_name) + '' + + '' + : '') + + '
'; + + const default_company = frappe.defaults.get_user_default('Company') || frappe.defaults.get_global_default('company'); + + const d = new frappe.ui.Dialog({ + title: __('Select employees from ƏMAS'), + size: 'large', + fields: [ + { + fieldname: 'header_html', + fieldtype: 'HTML', + options: headerHtml + }, + { + fieldname: 'create_designation', + fieldtype: 'Check', + label: __('Create Designation if not found'), + default: 1 + }, + { + fieldname: 'employees_html', + fieldtype: 'HTML', + options: invoiceTable + } + ], + primary_action_label: __('Load selected'), + primary_action: function(values) { + const selected_employees = []; + d.$wrapper.find('.select-employee:checked').each(function() { + const idx = $(this).data('index'); + selected_employees.push(employees[idx]); + }); + + if (selected_employees.length === 0) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('No employees selected') + }); + return; + } + + d.hide(); + create_employees(listview, asan_login_name, selected_employees, default_company, values.create_designation); + }, + secondary_action_label: __('Cancel'), + secondary_action: function() { d.hide(); } + }); + + // Match e-taxes dialog sizing + d.$wrapper.find('.modal-dialog').css({ + 'max-width': '80%', + 'width': '80%', + 'margin': '30px auto' + }); + + d.$wrapper.find('.modal-body').css({ + 'padding': '15px' + }); + + d.show(); + + // Select all checkbox + d.$wrapper.find('.select-all-employees').on('change', function() { + const isChecked = $(this).prop('checked'); + d.$wrapper.find('.select-employee').prop('checked', isChecked); + }); +} + +// Step 5: Create employees via backend - Socket.IO bulk import +// Wire realtime listeners for an in-flight ƏMAS import. Used both when the +// user starts a new import and when reattaching to an already-running one +// after a page reload. +function attach_amas_import_listeners(listview, asan_login_name, total_hint) { + const total = total_hint || 0; + + // Reused single confirm dialog. frappe.confirm creates a fresh dialog on + // each call and never removes the old element from DOM, which causes + // Bootstrap's modal-backdrop stack to glitch on repeat use. + let cancel_confirm_dialog = null; + + const ensure_cancel_button = function(dialog) { + if (!dialog || dialog._amas_cancel_attached) return; + dialog._amas_cancel_attached = true; + dialog.set_primary_action(__('Cancel Import'), function() { + if (!cancel_confirm_dialog) { + cancel_confirm_dialog = new frappe.ui.Dialog({ + title: __('Confirm'), + primary_action_label: __('Yes, cancel'), + primary_action: function() { + cancel_confirm_dialog.hide(); + frappe.call({ + method: 'invoice_az.amas_api.cancel_amas_import', + args: { asan_login_name: asan_login_name }, + callback: function() { + frappe.show_alert({ + message: __('Cancellation requested. Finishing current employee...'), + indicator: 'orange' + }, 5); + } + }); + }, + secondary_action_label: __('No, keep importing'), + secondary_action: function() { + cancel_confirm_dialog.hide(); + } + }); + cancel_confirm_dialog.$body.append( + '

' + __('Cancel the running ƏMAS import? Already-saved employees will stay; the rest will be skipped.') + '

' + ); + } + cancel_confirm_dialog.show(); + // Progress dialog forces z-index 2000; lift the confirm above it. + cancel_confirm_dialog.$wrapper.css('z-index', 2100); + }); + }; + + // Hand the reusable confirm dialog over to the import-complete handler so + // it can fully remove the leftover element from DOM when the import ends. + const cleanup_cancel_dialog = function() { + if (cancel_confirm_dialog) { + cancel_confirm_dialog.hide(); + cancel_confirm_dialog.$wrapper.remove(); + cancel_confirm_dialog = null; + } + }; + + // Защита от гонки realtime: 'complete' вызывает hide_progress() (обнуляет + // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который + // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. + let importFinished = false; + // True once we've observed the import actually running (via a realtime tick + // or a status poll). Guards the poll-based completion detector so it can't + // fire before the worker has even started (the running flag is set + // server-side a beat after import_bulk_employees is called). + let sawRunning = (total > 0); + // Highest overall percent (0-100) rendered so far. Realtime and polling both + // feed the bar; this keeps it monotonic so a stale poll can't rewind a + // fresher realtime tick (or vice-versa). + let lastPercent = 0; + + let dialog = frappe.show_progress( + __('Importing from ƏMAS'), 0, 100, + total + ? __('Starting import of {0} employees...', [total]) + : __('Preparing import...') + ); + ensure_cancel_button(dialog); + + const phaseLabel = function(phase) { + if (phase === 'save') return __('Saving employees'); + if (phase === 'fetch') return __('Fetching details'); + if (phase === 'connecting') return __('Connecting to ƏMAS...'); + if (phase === 'queued') return __('Waiting to start...'); + return __('Preparing import...'); + }; + + // The bar tracks ONE overall percent across both import phases (fetch = + // 0-50, save = 50-100), computed server-side. Fall back to deriving it the + // same way if an older worker sends no `percent`. + const overallPercent = function(percent, current, total, phase) { + if (typeof percent === 'number' && percent >= 0) return percent; + if (!(total > 0)) return 0; + const frac = Math.max(0, Math.min(1, current / total)); + if (phase === 'save') return 50 + frac * 50; + if (phase === 'fetch') return frac * 50; + return 0; + }; + + // Single entry point for moving the bar, fed by both the realtime handler + // and the status poll. Never regresses. + const renderProgress = function(percent, current, total, phase, employee_name) { + if (importFinished) return; + const pct = overallPercent(percent, current, total, phase); + if (pct > lastPercent) lastPercent = pct; + + const label = phaseLabel(phase); + let detail; + if (phase === 'queued' || phase === 'connecting' || !(total > 0)) { + detail = label; + } else if (employee_name) { + detail = label + ': ' + employee_name + ' (' + current + '/' + total + ')'; + } else { + detail = label + ': ' + __('{0} of {1}', [current, total]); + } + const d = frappe.show_progress( + __('Importing from ƏMAS'), Math.round(lastPercent), 100, detail + ); + ensure_cancel_button(d); + }; + + frappe.realtime.off('amas_import_progress'); + frappe.realtime.on('amas_import_progress', function(data) { + if (importFinished) return; + sawRunning = true; + renderProgress(data.percent, data.current, data.total, data.phase, data.employee_name); + }); + + // Polling fallback. Realtime events are transient — anything published + // before this page subscribed (after the wizard redirects to /app, or a + // reload) is lost, leaving the bar frozen. Polling the server-persisted + // progress guarantees the bar moves, and lets us detect completion even + // when the 'amas_import_complete' event itself was missed. + const finishFromPoll = function() { + if (importFinished) return; + importFinished = true; + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); // also clears amasPollTimer + cleanup_cancel_dialog(); + listview.refresh(); + frappe.show_alert({ message: __('ƏMAS import finished.'), indicator: 'green' }, 5); + }; + + const pollStatus = function() { + if (importFinished) return; + frappe.call({ + method: 'invoice_az.amas_api.get_amas_import_status', + args: { asan_login_name: asan_login_name }, + callback: function(r) { + if (importFinished) return; + const m = r.message || {}; + if (m.running) { + sawRunning = true; + renderProgress(m.percent, m.current, m.total, m.phase, m.employee_name); + } else if (sawRunning) { + // We watched it run and now it's gone — the realtime + // 'complete' was missed. Wrap up gracefully. + finishFromPoll(); + } + } + }); + }; + + if (amasPollTimer) clearInterval(amasPollTimer); + amasPollTimer = setInterval(pollStatus, 1000); + // Reconnect path (no total hint): the caller already confirmed an import is + // running, so seed the bar from real numbers immediately instead of waiting + // a full poll interval on the bare "preparing" text. + if (!total) pollStatus(); + + frappe.realtime.off('amas_import_complete'); + frappe.realtime.on('amas_import_complete', function(data) { + if (importFinished) return; // poll-based finish already wrapped up + importFinished = true; + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); + cleanup_cancel_dialog(); + + if (data.cancelled) { + frappe.msgprint({ + title: __('Import Cancelled'), + indicator: 'orange', + message: __( + 'Cancelled. Created: {0}, updated: {1}.', + [data.created || 0, data.updated || 0] + ) + }); + listview.refresh(); + return; + } + + if (data.aborted) { + const reason = (data.errors && data.errors[0] && data.errors[0].error) + || __('Import was aborted.'); + frappe.msgprint({ + title: __('Import Aborted'), + indicator: 'red', + message: reason + }); + listview.refresh(); + return; + } + + const error_messages = (data.errors || []).map(function(err) { + return (err.employee || 'Unknown') + ': ' + (err.error || 'Unknown error'); + }); + + const total_processed = data.created + data.updated + (data.errors || []).length; + show_import_summary(total_processed, data.created, data.updated, (data.errors || []).length, error_messages); + + listview.refresh(); + }); +} + +// Module-level handle for the status-poll interval (fallback that drives the +// progress bar when transient realtime events are missed). Only one ƏMAS import +// is ever tracked at a time, so a single timer is enough. closeAmasProgress() +// is the universal teardown path, so clearing it there covers every exit. +let amasPollTimer = null; + +// Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении +// (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно. +function closeAmasProgress() { + if (amasPollTimer) { + clearInterval(amasPollTimer); + amasPollTimer = null; + } + if (frappe.cur_progress) { + try { + frappe.cur_progress.$wrapper.modal('hide'); + frappe.cur_progress.$wrapper.remove(); + } catch (e) {} + frappe.cur_progress = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } +} + +function create_employees(listview, asan_login_name, selected_employees, company, create_designation) { + const total = selected_employees.length; + + attach_amas_import_listeners(listview, asan_login_name, total); + + frappe.call({ + method: 'invoice_az.amas_api.import_bulk_employees', + args: { + asan_login_name: asan_login_name, + employees_data: JSON.stringify(selected_employees), + company: company, + create_designation: create_designation ? 1 : 0 + }, + callback: function(r) { + if (r.message && r.message.already_running) { + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); + frappe.msgprint({ + title: __('Import Already Running'), + indicator: 'orange', + message: r.message.message + }); + return; + } + if (!r.message || !r.message.enqueued) { + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to start import job') + }); + } + }, + error: function() { + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Network error starting import') + }); + } + }); +} + +// Process employees one by one (like E-Taxes loadSelectedInvoices) +function process_employees_one_by_one(listview, asan_login_name, employees_queue, company, create_designation, + created_count, updated_count, error_count, errors, current_index) { + // Check if finished + if (employees_queue.length === 0) { + closeAmasProgress(); + $(document).off('progress-cancel.loading_amas_employees'); + + const total = created_count + updated_count + error_count; + show_import_summary(total, created_count, updated_count, error_count, errors); + + window.amas_employees_to_process = null; + listview.refresh(); + return; + } + + // Check if cancelled + if (window.amas_cancel_loading) { + employees_queue = []; + closeAmasProgress(); + $(document).off('progress-cancel.loading_amas_employees'); + + const total = created_count + updated_count + error_count; + frappe.show_alert({ + message: __('Import cancelled. Processed ') + total + __(' employees.'), + indicator: 'orange' + }, 5); + + listview.refresh(); + return; + } + + // Take first employee from queue + const emp_data = employees_queue.shift(); + const is_last = employees_queue.length === 0; + const total_to_process = window.amas_employees_to_process || (created_count + updated_count + error_count + employees_queue.length + 1); + + current_index++; + + // Show progress + frappe.show_progress( + __('Loading from ƏMAS'), + current_index - 1, + total_to_process, + __('Processing employee ') + current_index + __(' of ') + total_to_process, + null, + true + ); + + // Setup cancel handler + $(document).off('progress-cancel.loading_amas_employees'); + $(document).on('progress-cancel.loading_amas_employees', function() { + window.amas_cancel_loading = true; + frappe.show_alert({ + message: __('Cancelling import... Finishing current employee.'), + indicator: 'orange' + }, 3); + closeAmasProgress(); + }); + + // Get employee name for progress message + const emp_name = emp_data.full_name || emp_data.identification_number || 'Employee'; + + // Update progress with employee name + frappe.show_progress( + __('Loading from ƏMAS'), + current_index - 1, + total_to_process, + __('Processing: ') + emp_name + ); + + // Create/update single employee + frappe.call({ + method: 'invoice_az.amas_api.create_single_employee_from_amas', + args: { + asan_login_name: asan_login_name, + emp_data: emp_data, + company: company, + create_designation: create_designation ? 1 : 0 + }, + callback: function(r) { + if (r.message && r.message.success) { + // Success - increment counters + if (r.message.action === 'created') { + created_count++; + frappe.show_alert({ + message: __('Created: ') + r.message.full_name, + indicator: 'green' + }, 2); + } else if (r.message.action === 'updated') { + updated_count++; + frappe.show_alert({ + message: __('Updated: ') + r.message.full_name, + indicator: 'blue' + }, 2); + } + } else { + // Error + error_count++; + const error_msg = r.message ? r.message.message : 'Unknown error'; + errors.push(error_msg); + + frappe.show_alert({ + message: __('Error: ') + (r.message ? r.message.full_name : emp_name), + indicator: 'red' + }, 3); + } + + // Continue with next employee after short delay (like E-Taxes) + setTimeout(function() { + process_employees_one_by_one( + listview, + asan_login_name, + employees_queue, + company, + create_designation, + created_count, + updated_count, + error_count, + errors, + current_index + ); + }, 50); // 50ms delay like E-Taxes PROGRESS_UPDATE_DELAY + }, + error: function(xhr, status, error) { + // Network error + error_count++; + const error_msg = emp_name + ': Network error - ' + (error || 'Unknown error'); + errors.push(error_msg); + + frappe.show_alert({ + message: __('Network error: ') + emp_name, + indicator: 'red' + }, 3); + + // Continue with next employee + setTimeout(function() { + process_employees_one_by_one( + listview, + asan_login_name, + employees_queue, + company, + create_designation, + created_count, + updated_count, + error_count, + errors, + current_index + ); + }, 50); + } + }); +} + +// Show final summary (like E-Taxes) +function show_import_summary(total, created_count, updated_count, error_count, errors) { + if (error_count === 0) { + let msg = __('All ') + total + __(' employees were processed successfully.'); + if (created_count > 0) { + msg += '
' + __('Created: ') + '' + created_count + ''; + } + if (updated_count > 0) { + msg += '
' + __('Updated: ') + '' + updated_count + ''; + } + + frappe.msgprint({ + title: __('Import Completed Successfully'), + indicator: 'green', + message: msg + }); + } else { + let msg = '
'; + if (created_count > 0) { + msg += __('Successfully created: ') + '' + created_count + '
'; + } + if (updated_count > 0) { + msg += __('Updated: ') + '' + updated_count + '
'; + } + msg += __('Failed: ') + '' + error_count + ''; + msg += '
'; + + if (errors && errors.length > 0) { + msg += '
'; + msg += '
'; + } + + frappe.msgprint({ + title: created_count > 0 ? __('Import Completed with Errors') : __('Import Failed'), + indicator: created_count > 0 ? 'orange' : 'red', + message: msg + }); + } +} + +// Show a confirm dialog for ƏMAS permission errors with a Reconnect action. +// On confirm: runs full MyGovID + ƏMAS reauth and shows the organization +// selection dialog so the user can pick a different certificate. +function show_amas_permission_error_dialog(asan_login_name, message, retry_callback) { + const body = (message || __("The selected ƏMAS certificate doesn't have permission for this operation.")) + + '

' + + __('Would you like to reconnect to ƏMAS and choose a different organization?'); + + frappe.confirm( + body, + function() { + start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, true); + } + ); +} + +// Show reconnection dialog for ƏMAS when token expires (CSRF error) +function show_amas_reconnect_dialog(asan_login_name, retry_callback) { + const d = new frappe.ui.Dialog({ + title: __('ƏMAS Authentication Required'), + fields: [ + { + fieldname: 'message', + fieldtype: 'HTML', + options: ` +
+

+ +

+

Your ƏMAS session has expired or authentication is required.

+

Would you like to reconnect to ƏMAS?

+
+ ` + } + ], + primary_action_label: __('Reconnect'), + primary_action: function() { + d.hide(); + + // Try to reconnect to ƏMAS + frappe.call({ + method: 'invoice_az.amas_api.connect_amas', + args: { + 'asan_login_name': asan_login_name + }, + freeze: true, + freeze_message: __('Reconnecting to ƏMAS...'), + callback: function(r) { + if (r.message && r.message.success) { + // Check if we need to select organization + const accounts = r.message.accounts || []; + + if (accounts.length > 0) { + // Show organization selection dialog + show_amas_accounts_dialog(asan_login_name, accounts, retry_callback); + } else { + // No accounts to select, just proceed + frappe.show_alert({ + message: __('ƏMAS reconnected successfully!'), + indicator: 'green' + }, 3); + + // Retry the original operation + if (typeof retry_callback === 'function') { + setTimeout(retry_callback, 500); + } + } + } else if (r.message && r.message.need_mygovid_login) { + // MyGovID token expired - start full authentication flow with organization selection + start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, true); + } else { + frappe.msgprint({ + title: __('Reconnection Failed'), + indicator: 'red', + message: r.message ? r.message.message : __('Failed to reconnect to ƏMAS.') + }); + } + } + }); + }, + secondary_action_label: __('Cancel') + }); + + d.show(); +} + +// Start MyGovID authentication followed by ƏMAS connection (for employees) +// show_org_selection: true = show organization selection dialog, false = auto-select first org +function start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, show_org_selection) { + // Default to false (auto-select) for backward compatibility + if (show_org_selection === undefined) { + show_org_selection = false; + } + frappe.show_alert({ + message: __('Starting MyGovID authentication...'), + indicator: 'blue' + }, 3); + + // Create status dialog + const status_dialog = new frappe.ui.Dialog({ + title: __('MyGovID Authentication for ƏMAS'), + fields: [ + { + fieldname: 'status_html', + fieldtype: 'HTML', + options: ` +
+
+
+
+ +
+

Waiting for confirmation on your phone

+

Please check your phone and confirm the authentication request in ASAN Sign app.

+
+ +
+ ` + } + ], + primary_action_label: __('Cancel'), + primary_action: function() { + window.mygovidStop = true; + status_dialog.hide(); + } + }); + + status_dialog.show(); + window.mygovidStop = false; + + // Start MyGovID login + frappe.call({ + method: 'invoice_az.invoice_az.doctype.asan_login.asan_login.start_mygovid_login', + args: { + 'asan_login_name': asan_login_name + }, + timeout: 130000, + callback: function(r) { + if (window.mygovidStop) return; + + if (r.message && r.message.success) { + // MyGovID login successful, now connect to ƏMAS + $('#mygovid_status').html(` +

Connecting to ƏMAS...

+

MyGovID authentication successful. Now connecting to ƏMAS...

+ `); + $('#mygovid_code').hide(); + + // Connect to ƏMAS + frappe.call({ + method: 'invoice_az.amas_api.connect_amas', + args: { + 'asan_login_name': asan_login_name + }, + callback: function(r2) { + if (r2.message && r2.message.success) { + const accounts = r2.message.accounts || []; + + if (accounts.length > 0) { + // Check if we should show selection dialog or auto-select + if (show_org_selection) { + // Show organization selection dialog (for re-authentication) + status_dialog.hide(); + show_amas_accounts_dialog(asan_login_name, accounts, retry_callback); + } else { + // Auto-select first organization (for initial connection) + const first_account = accounts[0]; + const oid = first_account.accountOid || first_account.oid || ''; + const name = first_account.accountName || first_account.name || 'Unknown'; + const number = first_account.accountNumber || first_account.voen || first_account.number || ''; + const role = first_account.roleName || first_account.role || 'chairman'; + + $('#mygovid_status').html(` +

Selecting organization...

+

ƏMAS connected. Selecting ${frappe.utils.escape_html(name)}...

+ `); + + // Auto-select first account + frappe.call({ + method: 'invoice_az.amas_api.change_amas_account', + args: { + 'asan_login_name': asan_login_name, + 'account_oid': oid, + 'account_name': name, + 'account_number': number, + 'role_name': role + }, + callback: function(r3) { + if (r3.message && r3.message.success) { + $('#mygovid_status').html(` +

ƏMAS Connected!

+

Successfully connected to ${frappe.utils.escape_html(name)}.

+ `); + $('.mygovid-spinner').html(` +
+ +
+ `); + + setTimeout(function() { + status_dialog.hide(); + if (typeof retry_callback === 'function') { + retry_callback(); + } + }, 2000); + } else { + $('#mygovid_status').html(` +

Organization Selection Failed

+

${r3.message ? r3.message.message : 'Failed to select organization'}

+ `); + $('.mygovid-spinner').css('display', 'none'); + + status_dialog.set_primary_action(__('Close'), function() { + status_dialog.hide(); + }); + } + } + }); + } + } else { + // No accounts - just complete + $('#mygovid_status').html(` +

ƏMAS Connected!

+

Successfully connected to ƏMAS.

+ `); + $('.mygovid-spinner').html(` +
+ +
+ `); + + status_dialog.set_primary_action(__('Continue'), function() { + status_dialog.hide(); + if (typeof retry_callback === 'function') { + setTimeout(retry_callback, 500); + } + }); + + setTimeout(function() { + status_dialog.hide(); + if (typeof retry_callback === 'function') { + retry_callback(); + } + }, 2000); + } + } else { + $('#mygovid_status').html(` +

ƏMAS Connection Failed

+

${r2.message ? r2.message.message : 'Unknown error'}

+ `); + $('.mygovid-spinner').css('display', 'none'); + + status_dialog.set_primary_action(__('Close'), function() { + status_dialog.hide(); + }); + } + } + }); + } else { + // MyGovID login failed + $('#mygovid_status').html(` +

Authentication Failed

+

${r.message ? r.message.message : 'Unknown error'}

+ `); + $('.mygovid-spinner').css('display', 'none'); + $('#mygovid_code').hide(); + + status_dialog.set_primary_action(__('Close'), function() { + status_dialog.hide(); + }); + } + }, + error: function() { + if (window.mygovidStop) return; + + $('#mygovid_status').html(` +

Authentication Timeout

+

The request timed out. Please try again.

+ `); + $('.mygovid-spinner').css('display', 'none'); + $('#mygovid_code').hide(); + + status_dialog.set_primary_action(__('Close'), function() { + status_dialog.hide(); + }); + } + }); + + // Get verification code + setTimeout(function() { + frappe.call({ + method: 'invoice_az.invoice_az.doctype.asan_login.asan_login.get_mygovid_verification_code', + args: { 'asan_login_name': asan_login_name }, + callback: function(r) { + if (r.message && r.message.success) { + $('#mygovid_code_text').text(r.message.code); + $('#mygovid_code').show(); + } + } + }); + }, 500); +} + +// Show ƏMAS accounts selection dialog +function show_amas_accounts_dialog(asan_login_name, accounts, retry_callback) { + if (!accounts || accounts.length === 0) { + frappe.msgprint(__('No organizations found in ƏMAS')); + return; + } + + // Build accounts table + let table_html = '
'; + table_html += ''; + table_html += ''; + table_html += ''; + + accounts.forEach(function(account, index) { + const name = account.accountName || account.name || 'Unknown'; + const voen = account.accountNumber || account.voen || account.number || ''; + const role = account.roleName || account.role || ''; + const oid = account.accountOid || account.oid || ''; + + table_html += ` + + + + `; + }); + + table_html += '
Organization NameVOENAction
${frappe.utils.escape_html(name)}${frappe.utils.escape_html(voen)} + +
'; + + const d = new frappe.ui.Dialog({ + title: __('Select ƏMAS Organization'), + fields: [ + { + fieldname: 'info', + fieldtype: 'HTML', + options: '
Note: Please select the organization you want to work with.
' + }, + { + fieldname: 'accounts_html', + fieldtype: 'HTML', + options: table_html + } + ], + primary_action_label: __('Cancel'), + primary_action: function() { + d.hide(); + } + }); + + // Make dialog wider + d.$wrapper.find('.modal-dialog').css({ + 'max-width': '70%', + 'width': '70%' + }); + + d.show(); + + // Add click handler for account selection + d.$wrapper.find('.select-amas-account').on('click', function() { + const oid = $(this).data('oid'); + const name = $(this).data('name'); + const number = $(this).data('number'); + const role = $(this).data('role'); + + // Disable all buttons + d.$wrapper.find('.select-amas-account').prop('disabled', true); + $(this).html(' Selecting...'); + + // Call backend to select account + frappe.call({ + method: 'invoice_az.amas_api.change_amas_account', + args: { + 'asan_login_name': asan_login_name, + 'account_oid': oid, + 'account_name': name, + 'account_number': number, + 'role_name': role || 'chairman' + }, + callback: function(r) { + if (r.message && r.message.success) { + d.hide(); + frappe.show_alert({ + message: __('Organization selected: ') + name, + indicator: 'green' + }, 3); + + // Retry the original operation after short delay + if (typeof retry_callback === 'function') { + setTimeout(retry_callback, 500); + } + } else { + // Re-enable buttons on error + d.$wrapper.find('.select-amas-account').prop('disabled', false); + d.$wrapper.find('.select-amas-account').each(function() { + if ($(this).find('.fa-spinner').length) { + $(this).html('Select'); + } + }); + + if (r.message && r.message.permission_error) { + d.hide(); + show_amas_permission_error_dialog( + asan_login_name, + r.message.message, + retry_callback + ); + return; + } + + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Failed to select organization') + }); + } + } + }); + }); +} diff --git a/invoice_az/client/purchase_invoice.js b/invoice_az/client/purchase_invoice.js index ae1712b..d6e9b66 100644 --- a/invoice_az/client/purchase_invoice.js +++ b/invoice_az/client/purchase_invoice.js @@ -606,22 +606,23 @@ frappe.ui.form.on('Purchase Invoice', { // Make is_taxes_doc field read-only when document loads onload: function(frm) { frm.set_df_property('is_taxes_doc', 'read_only', 1); - - // Set visibility of purchase fields on load - toggle_purchase_fields_visibility(frm); }, purchase_type: function(frm) { - // Toggle visibility of tax_type and purchase_tax_amount fields - toggle_purchase_fields_visibility(frm); + // tax_type / purchase_tax_amount are always-visible columns; their + // mandatory state is driven by mandatory_depends_on (see custom_fields.py), + // so no manual grid toggling is needed. Just refresh to show/hide buttons. + frm.refresh(); + }, - // Refresh to show/hide buttons when checkbox changes + agricultural_goods: function(frm) { + // Equal trigger to purchase_type for button visibility. frm.refresh(); }, validate: function(frm) { // Client-side validation before save - if (frm.doc.purchase_type && frm.doc.items) { + if ((frm.doc.purchase_type || frm.doc.agricultural_goods) && frm.doc.items) { // Validate act_kind is selected if (!frm.doc.act_kind) { frappe.msgprint({ @@ -676,37 +677,14 @@ frappe.ui.form.on('Purchase Invoice Item', { } }); -/** - * Toggle visibility of purchase fields in items table - */ -function toggle_purchase_fields_visibility(frm) { - // Get the grid object - let grid = frm.fields_dict.items.grid; - - if (frm.doc.purchase_type) { - // Show fields - grid.update_docfield_property('tax_type', 'hidden', 0); - grid.update_docfield_property('purchase_tax_amount', 'hidden', 0); - grid.update_docfield_property('tax_type', 'reqd', 1); // Make required - } else { - // Hide fields - grid.update_docfield_property('tax_type', 'hidden', 1); - grid.update_docfield_property('purchase_tax_amount', 'hidden', 1); - grid.update_docfield_property('tax_type', 'reqd', 0); // Not required - } - - // Refresh grid to apply changes - grid.refresh(); -} - /** * Calculate purchase tax amount (5% if Taxable) */ function calculate_purchase_tax(frm, cdt, cdn) { let item = locals[cdt][cdn]; - // Only calculate if parent has purchase_type checked - if (!frm.doc.purchase_type) { + // Only calculate if parent has purchase_type or agricultural_goods checked + if (!frm.doc.purchase_type && !frm.doc.agricultural_goods) { frappe.model.set_value(cdt, cdn, 'purchase_tax_amount', 0); return; } @@ -1053,28 +1031,8 @@ function get_status_color(status) { return colors[status] || 'gray'; } -frappe.listview_settings['Purchase Invoice'] = { - onload: function(listview) { - // Add field as column (proper way) - listview.columns.push({ - type: 'Check', - df: { - label: __('Tax Doc'), - fieldname: 'is_taxes_doc' - }, - width: 120 - }); - - // Force refresh list with new column - listview.refresh(); - }, - - // Format is_taxes_doc field display - formatters: { - is_taxes_doc: function(value) { - return value ? - `` : - ``; - } - } -}; \ No newline at end of file +// NOTE: list view customizations live in client/purchase_invoice_list.js, +// loaded via the doctype_list_js hook. This file is form-only (doctype_js). +// Keeping frappe.ui.form.on(...) out of the list bundle prevents the form +// handlers (e.g. validate) from being registered twice, which previously +// caused validation messages to appear duplicated. \ No newline at end of file diff --git a/invoice_az/client/purchase_invoice_list.js b/invoice_az/client/purchase_invoice_list.js new file mode 100644 index 0000000..016be28 --- /dev/null +++ b/invoice_az/client/purchase_invoice_list.js @@ -0,0 +1,31 @@ +// List view customizations for Purchase Invoice. +// Loaded via the `doctype_list_js` hook ONLY. Do not add frappe.ui.form.on(...) +// handlers here — this file runs in list context and any form handler defined +// here would be registered a second time (in addition to client/purchase_invoice.js +// loaded by doctype_js), causing form events like `validate` to fire twice. + +frappe.listview_settings['Purchase Invoice'] = { + onload: function(listview) { + // Add field as column (proper way) + listview.columns.push({ + type: 'Check', + df: { + label: __('Tax Doc'), + fieldname: 'is_taxes_doc' + }, + width: 120 + }); + + // Force refresh list with new column + listview.refresh(); + }, + + // Format is_taxes_doc field display + formatters: { + is_taxes_doc: function(value) { + return value ? + `` : + ``; + } + } +}; diff --git a/invoice_az/client/purchase_order.js b/invoice_az/client/purchase_order.js index fb6b6ec..fb96b11 100644 --- a/invoice_az/client/purchase_order.js +++ b/invoice_az/client/purchase_order.js @@ -1567,56 +1567,6 @@ frappe.ui.form.on('Purchase Order', { } }); -// Purchase Order List -frappe.listview_settings['Purchase Order'] = { - add_fields: ['supplier', 'transaction_date', 'status', 'docstatus', 'is_taxes_doc', 'taxes_doc'], - - get_indicator: function(doc) { - if (doc.status === 'Closed') { - return [__('Closed'), 'green', 'status,=,Closed']; - } else if (doc.status === 'On Hold') { - return [__('On Hold'), 'orange', 'status,=,On Hold']; - } else if (doc.status === 'Delivered') { - return [__('Delivered'), 'green', 'status,=,Delivered']; - } else if (doc.docstatus == 1) { - return [__('To Receive and Bill'), 'orange', 'docstatus,=,1']; - } else if (doc.docstatus == 0) { - return [__('Draft'), 'red', 'docstatus,=,0']; - } else if (doc.docstatus == 2) { - return [__('Cancelled'), 'grey', 'docstatus,=,2']; - } - }, - - onload: function(listview) { - listview.page.add_inner_button(__('Import from E-Taxes'), function() { - ETaxes.import.showDialog(); - }, __('E-Taxes')); - - listview.page.add_inner_button(__('Open Settings'), function() { - frappe.set_route('Form', 'E-Taxes Settings'); - }, __('E-Taxes')); - - listview.columns.push({ - type: 'Check', - df: { - label: __('Tax Doc'), - fieldname: 'is_taxes_doc' - }, - width: 120 - }); - - listview.refresh(); - }, - - formatters: { - is_taxes_doc: function(value) { - return value ? - `` : - ``; - } - } -}; - // Purchase Invoice Form frappe.ui.form.on('Purchase Invoice', { refresh: function(frm) { @@ -1629,28 +1579,4 @@ frappe.ui.form.on('Purchase Invoice', { onload: function(frm) { frm.set_df_property('is_taxes_doc', 'read_only', 1); } -}); - -// Purchase Invoice List -frappe.listview_settings['Purchase Invoice'] = { - onload: function(listview) { - listview.columns.push({ - type: 'Check', - df: { - label: __('Tax Doc'), - fieldname: 'is_taxes_doc' - }, - width: 120 - }); - - listview.refresh(); - }, - - formatters: { - is_taxes_doc: function(value) { - return value ? - `` : - ``; - } - } -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/invoice_az/client/purchase_order_list.js b/invoice_az/client/purchase_order_list.js new file mode 100644 index 0000000..d7fcae0 --- /dev/null +++ b/invoice_az/client/purchase_order_list.js @@ -0,0 +1,1628 @@ +// List view settings for Purchase Order and Purchase Invoice. Loaded only via doctype_list_js. Do NOT add frappe.ui.form.on(...) here. + +// ======= ОБЩИЕ УТИЛИТЫ И КОНСТАНТЫ ======= +const ETaxes = { + // Константы + PROGRESS_UPDATE_DELAY: 50, + AUTH_POLL_INTERVAL: 6000, + AUTH_MAX_ATTEMPTS: 20, + + // Глобальные переменные + loadingDialog: null, + cancelLoading: false, + loadingErrors: [], + + // Кэш + cache: { + defaultLogin: null, + cacheTime: null, + cacheDuration: 300000 // 5 минут + } +}; + +// ======= БАЗОВЫЕ УТИЛИТЫ ======= +ETaxes.utils = { + // Кэшированное получение настроек входа + getDefaultLogin: function(callback, useCache = true) { + const now = Date.now(); + + if (useCache && ETaxes.cache.defaultLogin && ETaxes.cache.cacheTime && + (now - ETaxes.cache.cacheTime) < ETaxes.cache.cacheDuration) { + callback(ETaxes.cache.defaultLogin); + return; + } + + frappe.call({ + method: 'invoice_az.auth.get_default_asan_login', + callback: function(r) { + if (r.message) { + ETaxes.cache.defaultLogin = r.message; + ETaxes.cache.cacheTime = now; + } + callback(r.message); + }, + error: function() { + callback({found: false, error: 'Network error'}); + } + }); + }, + + // Проверка валидности токена + checkTokenValidity: function(callback) { + frappe.call({ + method: 'invoice_az.auth.check_token_validity', + callback: function(r) { + callback(r.message && r.message.valid); + }, + error: function() { + callback(false); + } + }); + }, + + // Форматирование валюты + formatCurrency: function(amount) { + return new Intl.NumberFormat('az-AZ', { + style: 'currency', + currency: 'AZN', + minimumFractionDigits: 2 + }).format(amount || 0); + }, + + // Очистка кэша + clearCache: function() { + ETaxes.cache.defaultLogin = null; + ETaxes.cache.cacheTime = null; + } +}; + +// ======= ДИАЛОГИ ЗАГРУЗКИ ======= +ETaxes.dialogs = { + // Показать диалог загрузки + showLoading: function(title, message, submessage) { + if (ETaxes.loadingDialog) { + this.updateLoading(title, message, submessage); + return; + } + + ETaxes.loadingDialog = new frappe.ui.Dialog({ + title: title, + fields: [{ + fieldname: 'loading_html', + fieldtype: 'HTML', + options: this._getLoadingHTML(title, message, submessage) + }], + primary_action_label: __('Cancel'), + primary_action: function() { + // Флаг ставим ПЕРЕД закрытием: hide() его больше не сбрасывает, + // поэтому in-flight callback'и пагинации увидят отмену и выйдут. + ETaxes.cancelLoading = true; + ETaxes.dialogs.hide(); + } + }); + + ETaxes.cancelLoading = false; + ETaxes.loadingDialog.show(); + ETaxes.loadingDialog.$wrapper.find('.modal-dialog').css('max-width', '450px'); + }, + + // Обновить сообщение в диалоге + updateLoading: function(title, message, submessage) { + if (!ETaxes.loadingDialog) return; + + if (title) $('#loading_title').text(title); + if (message) $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + }, + + // Показать код верификации + showVerificationCode: function(code) { + if (ETaxes.loadingDialog && code) { + $('#verification_code').text(code); + $('#verification_code_container').show(); + } + }, + + // Установить статус успеха + setSuccess: function(message, submessage, callback, delay = 2000) { + if (!ETaxes.loadingDialog) { + if (callback) callback(); + return; + } + + ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + + $('#verification_code_container').hide(); + ETaxes.loadingDialog.set_primary_action(null); + ETaxes.loadingDialog.set_secondary_action(null); + + setTimeout(() => { + this.hide(); + if (callback) callback(); + }, delay); + }, + + // Установить статус ошибки + setError: function(message, submessage, showCloseButton = true) { + if (!ETaxes.loadingDialog) return; + + ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + + $('#verification_code_container').hide(); + + if (showCloseButton) { + ETaxes.loadingDialog.set_primary_action(__('Close'), () => this.hide()); + } + }, + + // Скрыть диалог + hide: function() { + // Мягкий loadingDialog.hide() в jey_theme/Bootstrap НЕ всегда убирает модал + // (он зависает и следующий диалог накладывается сверху). Принудительно: + // modal('hide') + remove() + чистка осиротевшего backdrop. + // cancelLoading здесь НЕ сбрасываем — иначе отмена потеряется для in-flight + // callback'ов; флаг сбрасывается в начале операции / при создании диалога. + if (ETaxes.loadingDialog) { + try { + ETaxes.loadingDialog.$wrapper.modal('hide'); + ETaxes.loadingDialog.$wrapper.remove(); + } catch (e) { + console.error("Error hiding loading dialog:", e); + } + ETaxes.loadingDialog = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + + // Получить HTML для диалога загрузки + _getLoadingHTML: function(title, message, submessage) { + return ` +
+
+
+
+ +
+

${title || __('Please wait...')}

+

${message || __('The operation may take some time')}

+

${submessage || ''}

+
+ +
+ `; + } +}; + +// ======= МОДУЛЬ АУТЕНТИФИКАЦИИ ======= +ETaxes.auth = { + // Проверить токен и обработать аутентификацию при необходимости + checkAndProcess: function(callback) { + ETaxes.utils.checkTokenValidity(function(isValid) { + if (isValid) { + callback(); + } else { + frappe.confirm( + __('Your E-Taxes session has expired or authentication is required. Would you like to authenticate now?'), + function() { + ETaxes.auth.startProcess(callback); + }, + function() { + frappe.show_alert({ + message: __('Authentication canceled. Operation cannot be completed.'), + indicator: 'red' + }, 5); + } + ); + } + }); + }, + + // Начать процесс аутентификации + startProcess: function(callback) { + ETaxes.dialogs.showLoading( + __('Starting Authentication'), + __('Getting Asan Login settings...'), + __('Please wait') + ); + + ETaxes.utils.getDefaultLogin(function(loginData) { + if (loginData && loginData.found) { + const asanLoginName = loginData.name; + ETaxes.dialogs.updateLoading( + null, + __('Sending authentication request...'), + __('This will send a request to your Asan Imza mobile app') + ); + + frappe.call({ + method: 'invoice_az.auth.handle_authentication', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + const bearerToken = r.message.bearer_token; + + if (r.message.verification_code) { + ETaxes.dialogs.showVerificationCode(r.message.verification_code); + } + + ETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + ); + + ETaxes.auth.pollStatus(asanLoginName, bearerToken, function(success) { + if (success) { + ETaxes.auth.complete(asanLoginName, callback); + } else { + ETaxes.dialogs.setError( + __('Authentication Failed'), + __('Could not authenticate with Asan Imza') + ); + } + }); + } else { + ETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('Authentication request failed') + ); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('No Asan Login Settings'), + __('Please configure Asan Login settings first') + ); + } + }, false); // Не использовать кэш для аутентификации + }, + + // Опрос статуса аутентификации + pollStatus: function(asanLoginName, bearerToken, callback) { + let attempts = 0; + let stopPolling = false; + + function pollStatusStep() { + if (attempts >= ETaxes.AUTH_MAX_ATTEMPTS || stopPolling) { + if (attempts >= ETaxes.AUTH_MAX_ATTEMPTS) { + ETaxes.dialogs.setError( + __('Authentication Timeout'), + __('The authentication request has timed out. Please try again.') + ); + callback(false); + } + return; + } + + attempts++; + + ETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + + ' (' + attempts + '/' + ETaxes.AUTH_MAX_ATTEMPTS + ')' + ); + + frappe.call({ + method: 'invoice_az.auth.poll_auth_status', + args: { + 'asan_login_name': asanLoginName, + 'bearer_token': bearerToken + }, + callback: function(r) { + if (r.message && r.message.success) { + if (r.message.authenticated) { + stopPolling = true; + ETaxes.dialogs.setSuccess( + __('Authentication Successful'), + __('You are now authenticated with Asan Imza'), + function() { + callback(true); + } + ); + } else { + setTimeout(pollStatusStep, ETaxes.AUTH_POLL_INTERVAL); + } + } else { + stopPolling = true; + ETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('An unknown error occurred') + ); + callback(false); + } + }, + error: function(err) { + console.error('Error during status check:', err); + setTimeout(pollStatusStep, ETaxes.AUTH_POLL_INTERVAL); + } + }); + } + + pollStatusStep(); + }, + + // Завершение аутентификации + complete: function(asanLoginName, callback) { + ETaxes.dialogs.showLoading( + __('Completing Authentication'), + __('Getting certificates...'), + __('Please wait') + ); + + frappe.call({ + method: 'invoice_az.auth.get_auth_certificates', + args: { 'asan_login_name': asanLoginName }, + callback: function(certR) { + if (certR.message && certR.message.success) { + ETaxes.dialogs.updateLoading( + null, + __('Certificates retrieved'), + __('Selecting certificate and taxpayer') + ); + + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Asan Login', + name: asanLoginName + }, + callback: function(r) { + if (r.message && r.message.selected_certificate_json) { + try { + const certData = JSON.parse(r.message.selected_certificate_json); + const certName = r.message.selected_certificate; + + ETaxes.dialogs.updateLoading( + null, + __('Selecting certificate'), + certName + ); + + ETaxes.auth._selectCertificate(asanLoginName, certData, certName, callback); + } catch (e) { + console.error('Error parsing certificate:', e); + ETaxes.dialogs.hide(); + ETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } else { + ETaxes.dialogs.hide(); + ETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('Error'), + certR.message ? certR.message.message : __('Failed to get certificates') + ); + } + } + }); + }, + + // Выбор сертификата + _selectCertificate: function(asanLoginName, certData, certName, callback) { + frappe.call({ + method: 'invoice_az.auth.select_certificate', + args: { + 'asan_login_name': asanLoginName, + 'certificate_data': certData, + 'certificate_name': certName + }, + callback: function(r) { + if (r.message && r.message.success) { + ETaxes.dialogs.updateLoading( + null, + __('Selecting taxpayer'), + __('Using certificate: ') + certName + ); + + frappe.call({ + method: 'invoice_az.auth.select_taxpayer', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + ETaxes.utils.clearCache(); // Очищаем кэш после успешной аутентификации + ETaxes.dialogs.setSuccess( + __('Authentication Complete'), + __('You can now access E-Taxes services'), + callback + ); + } else { + ETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select taxpayer') + ); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select certificate') + ); + } + } + }); + }, + + // Показать селектор сертификатов + _showCertificateSelector: function(certificates, asanLoginName, callback) { + if (!certificates || !certificates.length) { + frappe.msgprint(__('No certificates available')); + return; + } + + let certHtml = '
'; + certHtml += ''; + + certificates.forEach(function(cert, index) { + let name = ''; + let id = ''; + + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + name = cert.individualInfo.name || ''; + id = cert.individualInfo.fin || ''; + } else if (cert.legalInfo) { + name = cert.legalInfo.name || ''; + id = cert.legalInfo.tin || cert.legalInfo.voen || ''; + } + + certHtml += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + certHtml += '
TypeNameIDPositionStatus
' + (cert.taxpayerType || '') + '' + name + '' + id + '' + (cert.position || '') + '' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + '
'; + + const dialog = new frappe.ui.Dialog({ + title: __('Select Certificate'), + fields: [{ + fieldtype: 'HTML', + fieldname: 'certificates', + options: certHtml + }] + }); + + dialog.show(); + + dialog.$wrapper.find('.select-cert').on('click', function() { + const index = $(this).data('index'); + const cert = certificates[index]; + + let certName = ''; + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + certName = `${cert.individualInfo.name} (${cert.individualInfo.fin})`; + } else if (cert.legalInfo) { + certName = `${cert.legalInfo.name} (${cert.legalInfo.voen || cert.legalInfo.tin})`; + } else { + certName = `Certificate ${index + 1}`; + } + + dialog.hide(); + + ETaxes.dialogs.showLoading( + __('Selecting Certificate'), + __('Processing your selection...'), + certName + ); + + ETaxes.auth._selectCertificate(asanLoginName, cert, certName, callback); + }); + } +}; + +// ======= УПРАВЛЕНИЕ ОШИБКАМИ ======= +ETaxes.errors = { + // Добавить ошибку в лог + add: function(invoiceData, errorType, errorMessage, additionalData = {}) { + 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', + error_type: errorType, + error_message: errorMessage, + timestamp: new Date().toISOString(), + ...additionalData + }; + + ETaxes.loadingErrors.push(errorEntry); + console.error('E-Taxes Import Error:', errorEntry); + }, + + // Очистить лог ошибок + clear: function() { + frappe.confirm( + __('Are you sure you want to clear the error log?'), + function() { + ETaxes.loadingErrors = []; + frappe.show_alert({ + message: __('Error log cleared'), + indicator: 'green' + }, 2); + } + ); + }, + + // Показать детальный лог ошибок + showDetails: function() { + if (ETaxes.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to display')); + return; + } + + let errorsHtml = '
'; + + ETaxes.loadingErrors.forEach(function(error, index) { + let documentDate = 'No date'; + if (error.creation_date && error.creation_date !== 'Unknown') { + try { + documentDate = moment(error.creation_date).format('DD.MM.YYYY'); + } catch (e) { + documentDate = 'Invalid date'; + } + } + + errorsHtml += '
'; + + // Заголовок ошибки + errorsHtml += '
'; + errorsHtml += '
'; + errorsHtml += '
' + + '' + (error.serial_number || error.invoice_id) + '
'; + errorsHtml += '' + error.error_type + ''; + errorsHtml += '
'; + errorsHtml += '' + error.sender_name + ''; + errorsHtml += '
'; + + // Сообщение об ошибке + errorsHtml += '
'; + errorsHtml += '' + error.error_message + ''; + errorsHtml += '
'; + + // Детали ошибки + if (error.unmatched_items && error.unmatched_items.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped items:') + '
'; + errorsHtml += '
    '; + error.unmatched_items.forEach(function(item, idx) { + errorsHtml += '
  • Row ' + (idx + 1) + ': ' + item.name; + if (item.code) { + errorsHtml += ' (Code: ' + item.code + ')'; + } + errorsHtml += '
  • '; + }); + errorsHtml += '
'; + } else if (error.unmatched_parties && error.unmatched_parties.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped parties:') + '
'; + errorsHtml += '
    '; + error.unmatched_parties.forEach(function(party, idx) { + errorsHtml += '
  • Party ' + (idx + 1) + ': ' + party.name; + if (party.tin) { + errorsHtml += ' (TIN: ' + party.tin + ')'; + } + if (party.type) { + errorsHtml += ' ' + party.type + ''; + } + errorsHtml += '
  • '; + }); + errorsHtml += '
'; + } else if (error.unmatched_units && error.unmatched_units.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped units:') + '
'; + errorsHtml += '
    '; + error.unmatched_units.forEach(function(unit, idx) { + errorsHtml += '
  • Unit ' + (idx + 1) + ': ' + unit.name + '
  • '; + }); + errorsHtml += '
'; + } + + errorsHtml += '
'; + errorsHtml += '' + documentDate + ''; + errorsHtml += '
'; + + errorsHtml += '
'; + }); + + errorsHtml += '
'; + + // Статистика + const errorTypes = {}; + ETaxes.loadingErrors.forEach(function(error) { + errorTypes[error.error_type] = (errorTypes[error.error_type] || 0) + 1; + }); + + let statsHtml = '
'; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += '' + __('Total Errors:') + ' ' + ETaxes.loadingErrors.length + '
'; + statsHtml += ''; + Object.keys(errorTypes).forEach(function(type, index) { + if (index > 0) statsHtml += ' • '; + statsHtml += type + ': ' + errorTypes[type]; + }); + statsHtml += ''; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += ''; + statsHtml += ''; + statsHtml += '
'; + + const d = new frappe.ui.Dialog({ + title: __('Error Details') + ' (' + ETaxes.loadingErrors.length + ')', + size: 'large', + fields: [ + { + fieldname: 'stats_html', + fieldtype: 'HTML', + options: statsHtml + }, + { + fieldname: 'errors_html', + fieldtype: 'HTML', + options: '
' + errorsHtml + '
' + } + ], + primary_action_label: __('Close'), + primary_action: function() { + d.hide(); + } + }); + + d.show(); + + // Добавляем обработчики событий + setTimeout(function() { + $('#export-csv-dialog-btn').off('click').on('click', function() { + ETaxes.errors.exportCSV(); + }); + + $('#clear-log-dialog-btn').off('click').on('click', function() { + ETaxes.errors.clear(); + d.hide(); + }); + }, 200); + }, + + // Экспорт в CSV + exportCSV: function() { + if (ETaxes.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to export')); + return; + } + + try { + let csvContent = "data:text/csv;charset=utf-8,"; + 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 row = [ + error.invoice_id, + error.serial_number, + error.creation_date, + cleanSender, + error.error_type, + cleanMessage, + moment(error.timestamp).format('DD.MM.YYYY HH:mm:ss') + ].map(function(field) { + return '"' + (field || '') + '"'; + }).join(','); + + csvContent += row + "\n"; + }); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "etaxes_import_errors_" + moment().format('YYYY-MM-DD_HH-mm-ss') + ".csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + frappe.show_alert({ + message: __('Error log exported successfully'), + indicator: 'green' + }, 3); + } catch (e) { + console.error('Export error:', e); + frappe.show_alert({ + message: __('Failed to export error log'), + indicator: 'red' + }, 3); + } + }, + + // Показать сводку результатов + showSummary: function(totalInvoices, processedCount, errorsCount) { + let title = ''; + let indicator = ''; + let message = ''; + + if (errorsCount === 0) { + title = __('Import Completed Successfully'); + indicator = 'green'; + message = __('All ') + totalInvoices + __(' invoices were imported successfully.'); + + frappe.msgprint({ + title: title, + indicator: indicator, + message: message + }); + } else { + title = errorsCount === totalInvoices ? __('Import Failed') : __('Import Completed with Errors'); + indicator = processedCount > 0 ? 'orange' : 'red'; + + message = '
'; + if (processedCount > 0) { + message += __('Successfully imported: ') + '' + processedCount + '' + __(' invoices') + '
'; + } + message += __('Failed: ') + '' + errorsCount + '' + __(' invoices'); + message += '
'; + + message += '
' + + '' + + '' + + '
'; + + const summaryDialog = frappe.msgprint({ + title: title, + indicator: indicator, + message: message + }); + + // Добавляем обработчики событий + setTimeout(function() { + $('#view-error-details-btn').off('click').on('click', function() { + summaryDialog.hide(); + ETaxes.errors.showDetails(); + }); + + $('#export-error-log-btn').off('click').on('click', function() { + ETaxes.errors.exportCSV(); + }); + }, 200); + } + } +}; + +// ======= МОДУЛЬ ИМПОРТА ИНВОЙСОВ ======= +ETaxes.import = { + // Показать диалог импорта из E-Taxes + showDialog: function() { + ETaxes.auth.checkAndProcess(function() { + const minDate = moment("2020-01-01", "YYYY-MM-DD"); + + const d = new frappe.ui.Dialog({ + title: __('Import invoice from E-Taxes'), + fields: [ + { + fieldname: 'date_range_section', + fieldtype: 'Section Break', + label: __('Period') + }, + { + fieldname: 'creationDateFrom', + fieldtype: 'Date', + label: __('Date from'), + default: moment().subtract(1, 'month').format('YYYY-MM-DD') + }, + { + fieldname: 'creationDateTo', + fieldtype: 'Date', + label: __('Date to'), + default: moment().format('YYYY-MM-DD') + }, + { + fieldname: 'settings_section', + fieldtype: 'Section Break', + label: __('Settings') + }, + { + fieldname: 'warehouse', + fieldtype: 'Link', + label: __('Default warehouse'), + options: 'Warehouse', + reqd: true, + get_query: function() { + return { + filters: { + 'is_group': 0, + 'disabled': 0 + } + }; + } + } + ], + primary_action_label: __('Search'), + primary_action: function() { + const values = d.get_values(); + + // Validate date range + const fromDateMoment = moment(values.creationDateFrom, "YYYY-MM-DD"); + const toDateMoment = moment(values.creationDateTo, "YYYY-MM-DD"); + const todayMoment = moment(); + + if (fromDateMoment.isBefore(minDate)) { + frappe.msgprint(__('From Date cannot be earlier than January 1, 2020')); + return; + } + + if (toDateMoment.isAfter(todayMoment)) { + frappe.msgprint(__('To Date cannot be later than today')); + return; + } + + if (toDateMoment.diff(fromDateMoment, 'days') > 366) { + frappe.msgprint(__('The date range cannot exceed one year.')); + return; + } + + if (!values.warehouse) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('Please select a default warehouse') + }); + return; + } + + const fromDate = moment(values.creationDateFrom).format('DD-MM-YYYY 00:00'); + const toDate = moment(values.creationDateTo).format('DD-MM-YYYY 23:59'); + + d.hide(); + ETaxes.import.loadInvoices(fromDate, toDate, values.warehouse); + } + }); + + d.show(); + }); + }, + + // Загрузить инвойсы с фильтрацией дубликатов + loadInvoices: function(fromDate, toDate, warehouse, accumulatedInvoices = [], offset = 0) { + ETaxes.cancelLoading = false; + $(document).off('progress-cancel.etaxes_import'); + + if (offset === 0) { + frappe.show_alert({ + message: __('Fetching E-Taxes invoices...'), + indicator: 'blue' + }, 3); + } + + ETaxes.utils.getDefaultLogin(function(loginResponse) { + if (ETaxes.cancelLoading) return; + + if (loginResponse && loginResponse.found) { + const mainToken = loginResponse.main_token; + + if (!mainToken) { + frappe.msgprint({ + title: __('Authentication Error'), + indicator: 'red', + message: __('Authentication token not found. Please complete the authentication process in E-Taxes settings.') + }); + return; + } + + const maxCount = 200; + + frappe.call({ + method: 'invoice_az.api.get_invoices', + args: { + 'token': mainToken, + 'filters': JSON.stringify({ + "creationDateFrom": fromDate, + "creationDateTo": toDate, + "maxCount": maxCount, + "offset": offset + }) + }, + callback: function(r) { + if (ETaxes.cancelLoading) return; + + if (r.message && !r.message.error) { + const currentInvoices = r.message.data || r.message.invoices || []; + const hasMore = r.message.hasMore || false; + const allInvoices = accumulatedInvoices.concat(currentInvoices); + + if (hasMore && currentInvoices.length > 0) { + const newOffset = offset + currentInvoices.length; + ETaxes.import.loadInvoices(fromDate, toDate, warehouse, allInvoices, newOffset); + } 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({ + title: __('Information'), + indicator: 'blue', + message: __('No invoices 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.loadInvoices(fromDate, toDate, warehouse); + }); + }, + 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 invoices') + }); + } + }, + error: function(xhr, status, error) { + console.error("Error fetching invoices:", error); + frappe.msgprint({ + title: __('Network Error'), + indicator: 'red', + message: __('Error fetching data from server: ') + (error || 'Unknown error') + }); + } + }); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('E-Taxes authentication settings not found') + }); + } + }); + }, + + // Фильтрация дубликатов + filterDuplicates: function(allInvoices, token, warehouse) { + frappe.call({ + method: 'invoice_az.api.get_etaxes_purchases', + callback: function(etaxesR) { + try { + if (ETaxes.cancelLoading) return; + + const importedInvoices = {}; + + if (etaxesR.message && etaxesR.message.success && etaxesR.message.purchases) { + etaxesR.message.purchases.forEach(function(purchase) { + if (purchase.etaxes_id) { + const normId = String(purchase.etaxes_id).trim(); + importedInvoices[normId] = true; + } + }); + } + + const filteredInvoices = []; + + for (let i = 0; i < allInvoices.length; i++) { + const invoice = allInvoices[i]; + const invoiceId = String(invoice.id).trim(); + + if (!importedInvoices.hasOwnProperty(invoiceId)) { + filteredInvoices.push(invoice); + } + } + + if (filteredInvoices.length > 0) { + ETaxes.import.showInvoiceSelection(filteredInvoices, token, warehouse); + } else { + frappe.msgprint({ + title: __('Information'), + indicator: 'blue', + message: __('No new invoices found for the specified period (all ' + + allInvoices.length + ' are already imported)') + }); + } + } catch (e) { + console.error("Error processing purchase data:", e); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('An error occurred while processing data: ') + e.message + }); + } + }, + error: function(err) { + console.error("Error fetching purchases:", err); + ETaxes.import.showInvoiceSelection(allInvoices, token, warehouse); + frappe.show_alert({ + message: __('Failed to check for duplicates. Showing all invoices.'), + indicator: 'orange' + }, 5); + } + }); + }, + + // Показать диалог выбора инвойсов + showInvoiceSelection: function(invoices, token, warehouse) { + let invoiceTable = '
'; + invoiceTable += '' + + '' + + '' + + '' + + '' + + '' + + ''; + + invoices.forEach(function(invoice) { + const serialNumber = invoice.serialNumber || ''; + + let creationDate = ''; + if (invoice.createdAt) { + creationDate = moment(invoice.createdAt).format('DD.MM.YYYY'); + } else if (invoice.creationDate) { + creationDate = moment(invoice.creationDate).format('DD.MM.YYYY'); + } + + const senderName = invoice.sender ? invoice.sender.name : (invoice.senderName || ''); + const amount = invoice.totalAmount || invoice.amount || 0; + + invoiceTable += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + invoiceTable += '
' + __('Number') + '' + __('Date') + '' + __('Supplier') + '' + __('Amount') + '
' + serialNumber + '' + creationDate + '' + senderName + '' + ETaxes.utils.formatCurrency(amount) + '
'; + + const infoMessage = + '
' + + '' + + __('New invoices found: ') + '' + invoices.length + '' + + '' + + '' + + __('Selected warehouse: ') + '' + frappe.utils.escape_html(warehouse) + '' + + '' + + '
'; + + const d = new frappe.ui.Dialog({ + title: __('Select invoice'), + size: 'large', + fields: [ + { + fieldname: 'info_html', + fieldtype: 'HTML', + options: infoMessage + }, + { + fieldname: 'invoices_html', + fieldtype: 'HTML', + options: invoiceTable + } + ], + primary_action_label: __('Load selected'), + primary_action: function() { + const selectedInvoiceIds = []; + d.$wrapper.find('.select-invoice:checked').each(function() { + selectedInvoiceIds.push($(this).data('id')); + }); + + if (selectedInvoiceIds.length === 0) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('No invoices selected') + }); + return; + } + + d.hide(); + ETaxes.cancelLoading = false; + ETaxes.import.loadSelectedInvoicesSocketIO(selectedInvoiceIds, token, warehouse); + }, + secondary_action_label: __('Cancel'), + secondary_action: function() { + d.hide(); + } + }); + + d.$wrapper.find('.modal-dialog').css({ + 'max-width': '80%', + 'width': '80%', + 'margin': '30px auto' + }); + + d.$wrapper.find('.modal-body').css({ + 'padding': '15px' + }); + + d.show(); + + // Обработчик для чекбокса "выбрать все" + d.$wrapper.find('.select-all-invoices').on('change', function() { + const isChecked = $(this).prop('checked'); + d.$wrapper.find('.select-invoice').prop('checked', isChecked); + }); + }, + + // Надёжное закрытие прогресс-бара. frappe.hide_progress() в jey_theme/Bootstrap + // не всегда убирает модал (он зависает, и summary/диалог накладывается сверху). + // Принудительно: modal('hide') + remove() + чистка осиротевшего backdrop. + _closeProgress: function() { + if (frappe.cur_progress) { + try { + frappe.cur_progress.$wrapper.modal('hide'); + frappe.cur_progress.$wrapper.remove(); + } catch (e) {} + frappe.cur_progress = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + + // Загрузка выбранных инвойсов + loadSelectedInvoices: function(invoiceIds, token, warehouse, processedCount = 0, currentIndex = 0) { + if (processedCount === 0 && currentIndex === 0) { + ETaxes.loadingErrors = []; + window.totalInvoicesToProcess = invoiceIds.length; + } + + $(document).off('progress-cancel.loading_invoices'); + + if (invoiceIds.length === 0) { + ETaxes.import._closeProgress(); + + const errorsCount = ETaxes.loadingErrors.length; + const totalInvoices = window.totalInvoicesToProcess || processedCount; + + ETaxes.errors.showSummary(totalInvoices, processedCount, errorsCount); + window.totalInvoicesToProcess = null; + + if (cur_list) { + cur_list.refresh(); + } + + return; + } + + if (ETaxes.cancelLoading) { + invoiceIds = []; + ETaxes.import._closeProgress(); + ETaxes.import.loadSelectedInvoices(invoiceIds, token, warehouse, processedCount, currentIndex); + return; + } + + const invoiceId = invoiceIds.shift(); + const isLastInvoice = invoiceIds.length === 0; + const totalToProcess = window.totalInvoicesToProcess || (processedCount + invoiceIds.length + 1); + + currentIndex++; + + frappe.show_progress(__('Importing Invoices'), currentIndex - 1, totalToProcess, + __('Loading invoice ') + currentIndex + __(' of ') + totalToProcess, null, true); + + $(document).on('progress-cancel.loading_invoices', function() { + ETaxes.cancelLoading = true; + frappe.show_alert({ + message: __('Cancelling import... Finishing current invoice.'), + indicator: 'orange' + }, 3); + ETaxes.import._closeProgress(); + }); + + frappe.call({ + method: 'invoice_az.api.get_invoice_details', + args: { + 'token': token, + 'invoice_id': invoiceId + }, + callback: function(r) { + 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 || ''; + const senderName = r.message.sender ? r.message.sender.name : ''; + const total = r.message.totalAmount || r.message.amount || 0; + + frappe.show_progress(__('Importing Invoices'), currentIndex - 1, totalToProcess, + __('Importing invoice: ') + serialNumber); + + frappe.call({ + method: 'invoice_az.api.import_invoice_with_mapping', + args: { + 'invoice_data': r.message, + 'purchase_order_name': null, + 'schedule_date': scheduleDate, + 'warehouse': warehouse + }, + callback: function(importR) { + ETaxes.import._handleImportResult(importR, r.message, invoiceId, scheduleDate, senderName, total, + invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + }, + error: function(xhr, status, error) { + const errorMessage = 'Network error during import: ' + (error || 'Unknown network error'); + ETaxes.errors.add(r.message, 'Network Error', errorMessage, { + invoice_items: r.message.items || [], + xhr_status: xhr.status, + xhr_response: xhr.responseText + }); + + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }); + } else if (r.message && r.message.error === 'unauthorized') { + ETaxes.import._closeProgress(); + $(document).off('progress-cancel.loading_invoices'); + + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + ETaxes.auth.startProcess(function() { + ETaxes.utils.getDefaultLogin(function(loginR) { + if (loginR && loginR.found && loginR.main_token) { + invoiceIds.unshift(invoiceId); + ETaxes.import.loadSelectedInvoices(invoiceIds, loginR.main_token, warehouse, processedCount, currentIndex - 1); + } else { + ETaxes.errors.add({id: invoiceId}, 'Authentication Error', + 'Failed to get token after authentication'); + + const errorsCount = ETaxes.loadingErrors.length; + ETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + } + }, false); + }); + }, + function() { + ETaxes.errors.add({id: invoiceId}, 'Authentication Cancelled', + 'User cancelled authentication process'); + + const errorsCount = ETaxes.loadingErrors.length; + ETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + } + ); + } else { + const errorMessage = r.message ? r.message.message : 'Failed to load invoice details'; + ETaxes.errors.add({id: invoiceId}, 'Invoice Data Error', errorMessage, { + server_response: r.message + }); + + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }, + error: function(xhr, status, error) { + const errorMessage = 'Network error loading invoice details: ' + (error || 'Unknown network error'); + ETaxes.errors.add({id: invoiceId}, 'Network Error', errorMessage, { + xhr_status: xhr.status, + xhr_response: xhr.responseText + }); + + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }); + }, + + // Обработка результата импорта + _handleImportResult: function(importR, invoiceMessage, invoiceId, scheduleDate, senderName, total, + invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice) { + if (importR.message && importR.message.success) { + frappe.show_alert({ + message: __('Invoice successfully imported: ') + (invoiceMessage.serialNumber || ''), + indicator: 'green' + }, 3); + + const purchaseOrderName = importR.message.purchase_order; + + frappe.call({ + method: 'invoice_az.api.create_etaxes_purchase', + args: { + 'etaxes_id': invoiceId, + 'date': scheduleDate, + 'party': senderName, + 'total': total + }, + callback: function(etaxesR) { + if (etaxesR.message && etaxesR.message.success) { + frappe.call({ + method: 'invoice_az.api.link_purchase_order_to_etaxes', + args: { + 'purchase_order': purchaseOrderName, + 'etaxes_purchase': etaxesR.message.name + }, + callback: function(linkR) { + processedCount++; + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }); + } else { + ETaxes.errors.add(invoiceMessage, 'E-Taxes Purchase Creation Error', + etaxesR.message ? etaxesR.message.message : 'Failed to create E-Taxes Purchase record', { + server_response: etaxesR.message + }); + + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + } + }); + } else if (importR.message && (importR.message.unmatched_items || importR.message.unmatched_parties || importR.message.unmatched_units)) { + let errorType = ''; + const additionalData = { invoice_items: invoiceMessage.items || [] }; + + if (importR.message.unmatched_items) { + errorType = 'Unmapped Items'; + additionalData.unmatched_items = importR.message.unmatched_items; + } else if (importR.message.unmatched_parties) { + errorType = 'Unmapped Parties'; + additionalData.unmatched_parties = importR.message.unmatched_parties; + } else if (importR.message.unmatched_units) { + errorType = 'Unmapped Units'; + additionalData.unmatched_units = importR.message.unmatched_units; + } + + additionalData.server_response = importR.message; + ETaxes.errors.add(invoiceMessage, errorType, importR.message.message, additionalData); + + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } else { + const errorMessage = importR.message ? importR.message.message : 'Unknown import error'; + const additionalData = { + invoice_items: invoiceMessage.items || [], + server_response: importR.message + }; + + if (importR.exc) { + additionalData.stack_trace = importR.exc; + } + + ETaxes.errors.add(invoiceMessage, 'Import Error', errorMessage, additionalData); + ETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }, + + // Продолжить или завершить обработку + _continueOrFinish: function(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice) { + if (isLastInvoice) { + ETaxes.import._closeProgress(); + $(document).off('progress-cancel.loading_invoices'); + + const totalToProcess = window.totalInvoicesToProcess || processedCount; + const errorsCount = ETaxes.loadingErrors.length; + ETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + + if (cur_list) { + cur_list.refresh(); + } + return; + } + + setTimeout(function() { + if (invoiceIds.length === 0) { + ETaxes.import._closeProgress(); + } + + ETaxes.import.loadSelectedInvoices(invoiceIds, token, warehouse, processedCount, currentIndex); + }, ETaxes.PROGRESS_UPDATE_DELAY); + }, + + // Socket.IO bulk import + loadSelectedInvoicesSocketIO: function(invoiceIds, token, warehouse) { + ETaxes.loadingErrors = []; + const total = invoiceIds.length; + // Защита от гонки realtime: 'complete' вызывает hide_progress() (обнуляет + // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который + // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. + let importFinished = false; + // Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении + // (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно. + const closeProgress = function() { + if (frappe.cur_progress) { + try { + frappe.cur_progress.$wrapper.modal('hide'); + frappe.cur_progress.$wrapper.remove(); + } catch (e) {} + frappe.cur_progress = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }; + + // Progress + cancel are shown by the global Background Tasks widget + // (bottom-right). No blocking modal here; we keep only the complete + // subscription to show the final import summary. + frappe.realtime.off('purchase_import_complete'); + frappe.realtime.on('purchase_import_complete', function(data) { + importFinished = true; + frappe.realtime.off('purchase_import_progress'); + frappe.realtime.off('purchase_import_complete'); + closeProgress(); + + (data.errors || []).forEach(function(err) { + ETaxes.loadingErrors.push({ + invoice_id: err.invoice_id || 'Unknown', + error_type: err.unmatched_items ? 'Unmapped Items' + : err.unmatched_parties ? 'Unmapped Parties' + : err.unmatched_units ? 'Unmapped Units' + : 'Import Error', + error_message: err.error || 'Unknown error', + unmatched_items: err.unmatched_items, + unmatched_parties: err.unmatched_parties, + unmatched_units: err.unmatched_units + }); + }); + + const errorsCount = ETaxes.loadingErrors.length; + ETaxes.errors.showSummary(data.total, data.imported, errorsCount); + + if (cur_list) { + cur_list.refresh(); + } + }); + + frappe.call({ + method: 'invoice_az.api.import_bulk_purchase_invoices', + args: { + invoice_ids: JSON.stringify(invoiceIds), + token: token, + warehouse: warehouse + }, + callback: function(r) { + if (!r.message || !r.message.enqueued) { + frappe.realtime.off('purchase_import_progress'); + frappe.realtime.off('purchase_import_complete'); + closeProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to start import job') + }); + } + }, + error: function() { + frappe.realtime.off('purchase_import_progress'); + frappe.realtime.off('purchase_import_complete'); + closeProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Network error starting import') + }); + } + }); + } +}; + +// Purchase Order List +frappe.listview_settings['Purchase Order'] = { + add_fields: ['supplier', 'transaction_date', 'status', 'docstatus', 'is_taxes_doc', 'taxes_doc'], + + get_indicator: function(doc) { + if (doc.status === 'Closed') { + return [__('Closed'), 'green', 'status,=,Closed']; + } else if (doc.status === 'On Hold') { + return [__('On Hold'), 'orange', 'status,=,On Hold']; + } else if (doc.status === 'Delivered') { + return [__('Delivered'), 'green', 'status,=,Delivered']; + } else if (doc.docstatus == 1) { + return [__('To Receive and Bill'), 'orange', 'docstatus,=,1']; + } else if (doc.docstatus == 0) { + return [__('Draft'), 'red', 'docstatus,=,0']; + } else if (doc.docstatus == 2) { + return [__('Cancelled'), 'grey', 'docstatus,=,2']; + } + }, + + onload: function(listview) { + listview.page.add_inner_button(__('Import from E-Taxes'), function() { + ETaxes.import.showDialog(); + }, __('E-Taxes')); + + listview.page.add_inner_button(__('Open Settings'), function() { + frappe.set_route('Form', 'E-Taxes Settings'); + }, __('E-Taxes')); + + listview.columns.push({ + type: 'Check', + df: { + label: __('Tax Doc'), + fieldname: 'is_taxes_doc' + }, + width: 120 + }); + + listview.refresh(); + }, + + formatters: { + is_taxes_doc: function(value) { + return value ? + `` : + ``; + } + } +}; + +// Purchase Invoice List +frappe.listview_settings['Purchase Invoice'] = { + onload: function(listview) { + listview.columns.push({ + type: 'Check', + df: { + label: __('Tax Doc'), + fieldname: 'is_taxes_doc' + }, + width: 120 + }); + + listview.refresh(); + }, + + formatters: { + is_taxes_doc: function(value) { + return value ? + `` : + ``; + } + } +}; \ No newline at end of file diff --git a/invoice_az/client/sales_invoice.js b/invoice_az/client/sales_invoice.js index ee9fbf6..8ec6a4c 100644 --- a/invoice_az/client/sales_invoice.js +++ b/invoice_az/client/sales_invoice.js @@ -2218,55 +2218,3 @@ function show_etaxes_details(frm) { message: details_html }); } - -/** - * List view settings - */ -frappe.listview_settings['Sales Invoice'] = { - // Make sure both signals reach the formatter, even though the column is bound - // to only one of them. - add_fields: ['is_taxes_doc', 'etaxes_send_status'], - - onload: function(listview) { - // Single universal E-Taxes column. It reflects everything related to the - // tax portal for this document: whether it was loaded from E-Taxes, - // whether a draft was created there, or whether it was sent and signed. - listview.columns.push({ - type: 'Select', - df: { - label: __('E-Taxes'), - fieldname: 'etaxes_send_status' - }, - width: 170 - }); - - // Force refresh list with the new column - listview.refresh(); - }, - - formatters: { - // Universal E-Taxes status. The formatter receives the full row as its - // third argument (value, df, doc), so we combine the send status with the - // "loaded from E-Taxes" flag into a single indicator. - etaxes_send_status: function(value, df, doc) { - const send_status = doc.etaxes_send_status; - - // Outgoing flow (document sent to the portal) takes priority — it is - // the most specific state. - if (send_status === 'Sent and Signed') { - return `${__('Sent to E-Taxes')}`; - } - if (send_status === 'Created, not signed') { - return `${__('Draft on E-Taxes')}`; - } - - // Incoming flow (document imported from the portal). - if (doc.is_taxes_doc) { - return `${__('Loaded from E-Taxes')}`; - } - - // Not related to the tax portal — keep the cell clean. - return ''; - } - } -}; diff --git a/invoice_az/client/sales_invoice_list.js b/invoice_az/client/sales_invoice_list.js new file mode 100644 index 0000000..76ba32b --- /dev/null +++ b/invoice_az/client/sales_invoice_list.js @@ -0,0 +1,50 @@ +// List view settings for Sales Invoice. Loaded only via doctype_list_js. Do NOT add frappe.ui.form.on(...) here. + +frappe.listview_settings['Sales Invoice'] = { + // Make sure both signals reach the formatter, even though the column is bound + // to only one of them. + add_fields: ['is_taxes_doc', 'etaxes_send_status'], + + onload: function(listview) { + // Single universal E-Taxes column. It reflects everything related to the + // tax portal for this document: whether it was loaded from E-Taxes, + // whether a draft was created there, or whether it was sent and signed. + listview.columns.push({ + type: 'Select', + df: { + label: __('E-Taxes'), + fieldname: 'etaxes_send_status' + }, + width: 170 + }); + + // Force refresh list with the new column + listview.refresh(); + }, + + formatters: { + // Universal E-Taxes status. The formatter receives the full row as its + // third argument (value, df, doc), so we combine the send status with the + // "loaded from E-Taxes" flag into a single indicator. + etaxes_send_status: function(value, df, doc) { + const send_status = doc.etaxes_send_status; + + // Outgoing flow (document sent to the portal) takes priority — it is + // the most specific state. + if (send_status === 'Sent and Signed') { + return `${__('Sent to E-Taxes')}`; + } + if (send_status === 'Created, not signed') { + return `${__('Draft on E-Taxes')}`; + } + + // Incoming flow (document imported from the portal). + if (doc.is_taxes_doc) { + return `${__('Loaded from E-Taxes')}`; + } + + // Not related to the tax portal — keep the cell clean. + return ''; + } + } +}; diff --git a/invoice_az/client/sales_order.js b/invoice_az/client/sales_order.js index a430b3a..bdbe4c7 100644 --- a/invoice_az/client/sales_order.js +++ b/invoice_az/client/sales_order.js @@ -1638,56 +1638,6 @@ frappe.ui.form.on('Sales Order', { } }); -frappe.listview_settings['Sales Order'] = { - add_fields: ['customer', 'transaction_date', 'status', 'docstatus', 'is_taxes_doc', 'taxes_doc'], - - get_indicator: function(doc) { - if (doc.status === 'Closed') { - return [__('Closed'), 'green', 'status,=,Closed']; - } else if (doc.status === 'On Hold') { - return [__('On Hold'), 'orange', 'status,=,On Hold']; - } else if (doc.status === 'Delivered') { - return [__('Delivered'), 'green', 'status,=,Delivered']; - } else if (doc.docstatus == 1) { - return [__('To Deliver and Bill'), 'orange', 'docstatus,=,1']; - } else if (doc.docstatus == 0) { - return [__('Draft'), 'red', 'docstatus,=,0']; - } else if (doc.docstatus == 2) { - return [__('Cancelled'), 'grey', 'docstatus,=,2']; - } - }, - - onload: function(listview) { - // ДОБАВЛЕНО: кнопка импорта из E-Taxes для Sales Order - listview.page.add_inner_button(__('Import from E-Taxes'), function() { - SalesETaxes.import.showDialog(); - }, __('E-Taxes')); - - listview.page.add_inner_button(__('Open Settings'), function() { - frappe.set_route('Form', 'E-Taxes Settings'); - }, __('E-Taxes')); - - listview.columns.push({ - type: 'Check', - df: { - label: __('Tax Doc'), - fieldname: 'is_taxes_doc' - }, - width: 120 - }); - - listview.refresh(); - }, - - formatters: { - is_taxes_doc: function(value) { - return value ? - `` : - ``; - } - } -}; - // Sales Invoice Form frappe.ui.form.on('Sales Invoice', { refresh: function(frm) { @@ -1700,32 +1650,4 @@ frappe.ui.form.on('Sales Invoice', { onload: function(frm) { frm.set_df_property('is_taxes_doc', 'read_only', 1); } -}); - -// Sales Invoice List - УБРАНО: кнопка импорта (теперь только в Sales Order) -frappe.listview_settings['Sales Invoice'] = { - add_fields: ['customer', 'posting_date', 'status', 'docstatus', 'is_taxes_doc'], - - onload: function(listview) { - // УБРАНО: кнопка импорта - теперь импорт только через Sales Order - - listview.columns.push({ - type: 'Check', - df: { - label: __('Tax Doc'), - fieldname: 'is_taxes_doc' - }, - width: 120 - }); - - listview.refresh(); - }, - - formatters: { - is_taxes_doc: function(value) { - return value ? - `` : - ``; - } - } -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/invoice_az/client/sales_order_list.js b/invoice_az/client/sales_order_list.js new file mode 100644 index 0000000..6343222 --- /dev/null +++ b/invoice_az/client/sales_order_list.js @@ -0,0 +1,1703 @@ +// List view settings for Sales Order and Sales Invoice. Loaded only via doctype_list_js. Do NOT add frappe.ui.form.on(...) here. + +// ======= ОБЩИЕ УТИЛИТЫ И КОНСТАНТЫ ======= +const SalesETaxes = { + // Константы + PROGRESS_UPDATE_DELAY: 50, + AUTH_POLL_INTERVAL: 6000, + AUTH_MAX_ATTEMPTS: 20, + + // Глобальные переменные + loadingDialog: null, + cancelLoading: false, + loadingErrors: [], + + // Кэш + cache: { + defaultLogin: null, + cacheTime: null, + cacheDuration: 300000 // 5 минут + } +}; + +// ======= БАЗОВЫЕ УТИЛИТЫ ======= +SalesETaxes.utils = { + // Кэшированное получение настроек входа + getDefaultLogin: function(callback, useCache = true) { + const now = Date.now(); + + if (useCache && SalesETaxes.cache.defaultLogin && SalesETaxes.cache.cacheTime && + (now - SalesETaxes.cache.cacheTime) < SalesETaxes.cache.cacheDuration) { + callback(SalesETaxes.cache.defaultLogin); + return; + } + + frappe.call({ + method: 'invoice_az.auth.get_default_asan_login', + callback: function(r) { + if (r.message) { + SalesETaxes.cache.defaultLogin = r.message; + SalesETaxes.cache.cacheTime = now; + } + callback(r.message); + }, + error: function() { + callback({found: false, error: 'Network error'}); + } + }); + }, + + // Проверка валидности токена + checkTokenValidity: function(callback) { + frappe.call({ + method: 'invoice_az.auth.check_token_validity', + callback: function(r) { + callback(r.message && r.message.valid); + }, + error: function() { + callback(false); + } + }); + }, + + // Форматирование валюты + formatCurrency: function(amount) { + return new Intl.NumberFormat('az-AZ', { + style: 'currency', + currency: 'AZN', + minimumFractionDigits: 2 + }).format(amount || 0); + }, + + // Очистка кэша + clearCache: function() { + SalesETaxes.cache.defaultLogin = null; + SalesETaxes.cache.cacheTime = null; + } +}; + +// ======= ДИАЛОГИ ЗАГРУЗКИ ======= +SalesETaxes.dialogs = { + // Показать диалог загрузки + showLoading: function(title, message, submessage) { + if (SalesETaxes.loadingDialog) { + this.updateLoading(title, message, submessage); + return; + } + + SalesETaxes.loadingDialog = new frappe.ui.Dialog({ + title: title, + fields: [{ + fieldname: 'loading_html', + fieldtype: 'HTML', + options: this._getLoadingHTML(title, message, submessage) + }], + primary_action_label: __('Cancel'), + primary_action: function() { + // Флаг ставим ПЕРЕД закрытием: hide() его больше не сбрасывает, + // поэтому in-flight callback'и пагинации увидят отмену и выйдут. + SalesETaxes.cancelLoading = true; + SalesETaxes.dialogs.hide(); + } + }); + + SalesETaxes.cancelLoading = false; + SalesETaxes.loadingDialog.show(); + SalesETaxes.loadingDialog.$wrapper.find('.modal-dialog').css('max-width', '450px'); + }, + + // Обновить сообщение в диалоге + updateLoading: function(title, message, submessage) { + if (!SalesETaxes.loadingDialog) return; + + if (title) $('#loading_title').text(title); + if (message) $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + }, + + // Показать код верификации + showVerificationCode: function(code) { + if (SalesETaxes.loadingDialog && code) { + $('#verification_code').text(code); + $('#verification_code_container').show(); + } + }, + + // Установить статус успеха + setSuccess: function(message, submessage, callback, delay = 2000) { + if (!SalesETaxes.loadingDialog) { + if (callback) callback(); + return; + } + + SalesETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + + $('#verification_code_container').hide(); + SalesETaxes.loadingDialog.set_primary_action(null); + SalesETaxes.loadingDialog.set_secondary_action(null); + + setTimeout(() => { + this.hide(); + if (callback) callback(); + }, delay); + }, + + // Установить статус ошибки + setError: function(message, submessage, showCloseButton = true) { + if (!SalesETaxes.loadingDialog) return; + + SalesETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + + $('#verification_code_container').hide(); + + if (showCloseButton) { + SalesETaxes.loadingDialog.set_primary_action(__('Close'), () => this.hide()); + } + }, + + // Скрыть диалог. Мягкий loadingDialog.hide() в jey_theme/Bootstrap НЕ всегда + // убирает модал (он зависает и следующий диалог накладывается сверху). Поэтому + // принудительно: modal('hide') + remove() + чистка осиротевшего backdrop. + // cancelLoading здесь НЕ сбрасываем: иначе отмена потеряется для in-flight + // callback'ов. Флаг сбрасывается в начале операции (loadInvoices/showLoading). + hide: function() { + if (SalesETaxes.loadingDialog) { + try { + SalesETaxes.loadingDialog.$wrapper.modal('hide'); + SalesETaxes.loadingDialog.$wrapper.remove(); + } catch (e) { + console.error("Error hiding loading dialog:", e); + } + SalesETaxes.loadingDialog = null; + } + // backdrop/scroll-lock убираем только если не осталось видимых модалов + // (иначе сломаем диалог, который показывается следом, напр. выбор даты). + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + + // Получить HTML для диалога загрузки + _getLoadingHTML: function(title, message, submessage) { + return ` +
+
+
+
+ +
+

${title || __('Please wait...')}

+

${message || __('The operation may take some time')}

+

${submessage || ''}

+
+ +
+ `; + } +}; + +// ======= МОДУЛЬ АУТЕНТИФИКАЦИИ ======= +SalesETaxes.auth = { + // Проверить токен и обработать аутентификацию при необходимости + checkAndProcess: function(callback) { + SalesETaxes.utils.checkTokenValidity(function(isValid) { + if (isValid) { + callback(); + } else { + frappe.confirm( + __('Your E-Taxes session has expired or authentication is required. Would you like to authenticate now?'), + function() { + SalesETaxes.auth.startProcess(callback); + }, + function() { + frappe.show_alert({ + message: __('Authentication canceled. Operation cannot be completed.'), + indicator: 'red' + }, 5); + } + ); + } + }); + }, + + // Начать процесс аутентификации + startProcess: function(callback) { + SalesETaxes.dialogs.showLoading( + __('Starting Authentication'), + __('Getting Asan Login settings...'), + __('Please wait') + ); + + SalesETaxes.utils.getDefaultLogin(function(loginData) { + if (loginData && loginData.found) { + const asanLoginName = loginData.name; + SalesETaxes.dialogs.updateLoading( + null, + __('Sending authentication request...'), + __('This will send a request to your Asan Imza mobile app') + ); + + frappe.call({ + method: 'invoice_az.auth.handle_authentication', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + const bearerToken = r.message.bearer_token; + + if (r.message.verification_code) { + SalesETaxes.dialogs.showVerificationCode(r.message.verification_code); + } + + SalesETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + ); + + SalesETaxes.auth.pollStatus(asanLoginName, bearerToken, function(success) { + if (success) { + SalesETaxes.auth.complete(asanLoginName, callback); + } else { + SalesETaxes.dialogs.setError( + __('Authentication Failed'), + __('Could not authenticate with Asan Imza') + ); + } + }); + } else { + SalesETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('Authentication request failed') + ); + } + } + }); + } else { + SalesETaxes.dialogs.setError( + __('No Asan Login Settings'), + __('Please configure Asan Login settings first') + ); + } + }, false); // Не использовать кэш для аутентификации + }, + + // Опрос статуса аутентификации + pollStatus: function(asanLoginName, bearerToken, callback) { + let attempts = 0; + let stopPolling = false; + + function pollStatusStep() { + if (attempts >= SalesETaxes.AUTH_MAX_ATTEMPTS || stopPolling) { + if (attempts >= SalesETaxes.AUTH_MAX_ATTEMPTS) { + SalesETaxes.dialogs.setError( + __('Authentication Timeout'), + __('The authentication request has timed out. Please try again.') + ); + callback(false); + } + return; + } + + attempts++; + + SalesETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + + ' (' + attempts + '/' + SalesETaxes.AUTH_MAX_ATTEMPTS + ')' + ); + + frappe.call({ + method: 'invoice_az.auth.poll_auth_status', + args: { + 'asan_login_name': asanLoginName, + 'bearer_token': bearerToken + }, + callback: function(r) { + if (r.message && r.message.success) { + if (r.message.authenticated) { + stopPolling = true; + SalesETaxes.dialogs.setSuccess( + __('Authentication Successful'), + __('You are now authenticated with Asan Imza'), + function() { + callback(true); + } + ); + } else { + setTimeout(pollStatusStep, SalesETaxes.AUTH_POLL_INTERVAL); + } + } else { + stopPolling = true; + SalesETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('An unknown error occurred') + ); + callback(false); + } + }, + error: function(err) { + console.error('Error during status check:', err); + setTimeout(pollStatusStep, SalesETaxes.AUTH_POLL_INTERVAL); + } + }); + } + + pollStatusStep(); + }, + + // Завершение аутентификации + complete: function(asanLoginName, callback) { + SalesETaxes.dialogs.showLoading( + __('Completing Authentication'), + __('Getting certificates...'), + __('Please wait') + ); + + frappe.call({ + method: 'invoice_az.auth.get_auth_certificates', + args: { 'asan_login_name': asanLoginName }, + callback: function(certR) { + if (certR.message && certR.message.success) { + SalesETaxes.dialogs.updateLoading( + null, + __('Certificates retrieved'), + __('Selecting certificate and taxpayer') + ); + + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Asan Login', + name: asanLoginName + }, + callback: function(r) { + if (r.message && r.message.selected_certificate_json) { + try { + const certData = JSON.parse(r.message.selected_certificate_json); + const certName = r.message.selected_certificate; + + SalesETaxes.dialogs.updateLoading( + null, + __('Selecting certificate'), + certName + ); + + SalesETaxes.auth._selectCertificate(asanLoginName, certData, certName, callback); + } catch (e) { + console.error('Error parsing certificate:', e); + SalesETaxes.dialogs.hide(); + SalesETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } else { + SalesETaxes.dialogs.hide(); + SalesETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } + }); + } else { + SalesETaxes.dialogs.setError( + __('Error'), + certR.message ? certR.message.message : __('Failed to get certificates') + ); + } + } + }); + }, + + // Выбор сертификата + _selectCertificate: function(asanLoginName, certData, certName, callback) { + frappe.call({ + method: 'invoice_az.auth.select_certificate', + args: { + 'asan_login_name': asanLoginName, + 'certificate_data': certData, + 'certificate_name': certName + }, + callback: function(r) { + if (r.message && r.message.success) { + SalesETaxes.dialogs.updateLoading( + null, + __('Selecting taxpayer'), + __('Using certificate: ') + certName + ); + + frappe.call({ + method: 'invoice_az.auth.select_taxpayer', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + SalesETaxes.utils.clearCache(); // Очищаем кэш после успешной аутентификации + SalesETaxes.dialogs.setSuccess( + __('Authentication Complete'), + __('You can now access E-Taxes services'), + callback + ); + } else { + SalesETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select taxpayer') + ); + } + } + }); + } else { + SalesETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select certificate') + ); + } + } + }); + }, + + // Показать селектор сертификатов + _showCertificateSelector: function(certificates, asanLoginName, callback) { + if (!certificates || !certificates.length) { + frappe.msgprint(__('No certificates available')); + return; + } + + let certHtml = '
'; + certHtml += ''; + + certificates.forEach(function(cert, index) { + let name = ''; + let id = ''; + + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + name = cert.individualInfo.name || ''; + id = cert.individualInfo.fin || ''; + } else if (cert.legalInfo) { + name = cert.legalInfo.name || ''; + id = cert.legalInfo.tin || cert.legalInfo.voen || ''; + } + + certHtml += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + certHtml += '
TypeNameIDPositionStatus
' + (cert.taxpayerType || '') + '' + name + '' + id + '' + (cert.position || '') + '' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + '
'; + + const dialog = new frappe.ui.Dialog({ + title: __('Select Certificate'), + fields: [{ + fieldtype: 'HTML', + fieldname: 'certificates', + options: certHtml + }] + }); + + dialog.show(); + + dialog.$wrapper.find('.select-cert').on('click', function() { + const index = $(this).data('index'); + const cert = certificates[index]; + + let certName = ''; + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + certName = `${cert.individualInfo.name} (${cert.individualInfo.fin})`; + } else if (cert.legalInfo) { + certName = `${cert.legalInfo.name} (${cert.legalInfo.voen || cert.legalInfo.tin})`; + } else { + certName = `Certificate ${index + 1}`; + } + + dialog.hide(); + + SalesETaxes.dialogs.showLoading( + __('Selecting Certificate'), + __('Processing your selection...'), + certName + ); + + SalesETaxes.auth._selectCertificate(asanLoginName, cert, certName, callback); + }); + } +}; + +// ======= УПРАВЛЕНИЕ ОШИБКАМИ ======= +SalesETaxes.errors = { + // Добавить ошибку в лог + add: function(invoiceData, errorType, errorMessage, additionalData = {}) { + const errorEntry = { + invoice_id: invoiceData.id || 'Unknown', + serial_number: invoiceData.serialNumber || 'Unknown', + receiver_name: invoiceData.receiver ? invoiceData.receiver.name : 'Unknown', + creation_date: invoiceData.creationDate || invoiceData.createdAt || invoiceData.date || 'Unknown', + error_type: errorType, + error_message: errorMessage, + timestamp: new Date().toISOString(), + ...additionalData + }; + + SalesETaxes.loadingErrors.push(errorEntry); + console.error('E-Taxes Sales Import Error:', errorEntry); + }, + + // Очистить лог ошибок + clear: function() { + frappe.confirm( + __('Are you sure you want to clear the error log?'), + function() { + SalesETaxes.loadingErrors = []; + frappe.show_alert({ + message: __('Error log cleared'), + indicator: 'green' + }, 2); + } + ); + }, + + // Показать детальный лог ошибок + showDetails: function() { + if (SalesETaxes.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to display')); + return; + } + + let errorsHtml = '
'; + + SalesETaxes.loadingErrors.forEach(function(error, index) { + let documentDate = 'No date'; + if (error.creation_date && error.creation_date !== 'Unknown') { + try { + documentDate = moment(error.creation_date).format('DD.MM.YYYY'); + } catch (e) { + documentDate = 'Invalid date'; + } + } + + errorsHtml += '
'; + + // Заголовок ошибки + errorsHtml += '
'; + errorsHtml += '
'; + errorsHtml += '
' + + '' + (error.serial_number || error.invoice_id) + '
'; + errorsHtml += '' + error.error_type + ''; + errorsHtml += '
'; + errorsHtml += '' + error.receiver_name + ''; + errorsHtml += '
'; + + // Сообщение об ошибке + errorsHtml += '
'; + errorsHtml += '' + error.error_message + ''; + errorsHtml += '
'; + + // Детали ошибки + if (error.unmatched_items && error.unmatched_items.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped items:') + '
'; + errorsHtml += '
    '; + error.unmatched_items.forEach(function(item, idx) { + errorsHtml += '
  • Row ' + (idx + 1) + ': ' + item.name; + if (item.code) { + errorsHtml += ' (Code: ' + item.code + ')'; + } + errorsHtml += '
  • '; + }); + errorsHtml += '
'; + } else if (error.unmatched_parties && error.unmatched_parties.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped parties:') + '
'; + errorsHtml += '
    '; + error.unmatched_parties.forEach(function(party, idx) { + errorsHtml += '
  • Party ' + (idx + 1) + ': ' + party.name; + if (party.tin) { + errorsHtml += ' (TIN: ' + party.tin + ')'; + } + if (party.type) { + errorsHtml += ' ' + party.type + ''; + } + errorsHtml += '
  • '; + }); + errorsHtml += '
'; + } else if (error.unmatched_units && error.unmatched_units.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped units:') + '
'; + errorsHtml += '
    '; + error.unmatched_units.forEach(function(unit, idx) { + errorsHtml += '
  • Unit ' + (idx + 1) + ': ' + unit.name + '
  • '; + }); + errorsHtml += '
'; + } + + errorsHtml += '
'; + errorsHtml += '' + documentDate + ''; + errorsHtml += '
'; + + errorsHtml += '
'; + }); + + errorsHtml += '
'; + + // Статистика + const errorTypes = {}; + SalesETaxes.loadingErrors.forEach(function(error) { + errorTypes[error.error_type] = (errorTypes[error.error_type] || 0) + 1; + }); + + let statsHtml = '
'; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += '' + __('Total Errors:') + ' ' + SalesETaxes.loadingErrors.length + '
'; + statsHtml += ''; + Object.keys(errorTypes).forEach(function(type, index) { + if (index > 0) statsHtml += ' • '; + statsHtml += type + ': ' + errorTypes[type]; + }); + statsHtml += ''; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += ''; + statsHtml += ''; + statsHtml += '
'; + + const d = new frappe.ui.Dialog({ + title: __('Error Details') + ' (' + SalesETaxes.loadingErrors.length + ')', + size: 'large', + fields: [ + { + fieldname: 'stats_html', + fieldtype: 'HTML', + options: statsHtml + }, + { + fieldname: 'errors_html', + fieldtype: 'HTML', + options: '
' + errorsHtml + '
' + } + ], + primary_action_label: __('Close'), + primary_action: function() { + d.hide(); + } + }); + + d.show(); + + // Добавляем обработчики событий + setTimeout(function() { + $('#export-csv-dialog-btn').off('click').on('click', function() { + SalesETaxes.errors.exportCSV(); + }); + + $('#clear-log-dialog-btn').off('click').on('click', function() { + SalesETaxes.errors.clear(); + d.hide(); + }); + }, 200); + }, + + // Экспорт в CSV + exportCSV: function() { + if (SalesETaxes.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to export')); + return; + } + + try { + let csvContent = "data:text/csv;charset=utf-8,"; + csvContent += "Invoice ID,Serial Number,Date,Customer,Error Type,Error Message,Timestamp\n"; + + SalesETaxes.loadingErrors.forEach(function(error) { + const cleanMessage = error.error_message.replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' '); + const cleanReceiver = error.receiver_name.replace(/"/g, '""'); + + const row = [ + error.invoice_id, + error.serial_number, + error.creation_date, + cleanReceiver, + error.error_type, + cleanMessage, + moment(error.timestamp).format('DD.MM.YYYY HH:mm:ss') + ].map(function(field) { + return '"' + (field || '') + '"'; + }).join(','); + + csvContent += row + "\n"; + }); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "etaxes_sales_import_errors_" + moment().format('YYYY-MM-DD_HH-mm-ss') + ".csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + frappe.show_alert({ + message: __('Error log exported successfully'), + indicator: 'green' + }, 3); + } catch (e) { + console.error('Export error:', e); + frappe.show_alert({ + message: __('Failed to export error log'), + indicator: 'red' + }, 3); + } + }, + + // Показать сводку результатов + showSummary: function(totalInvoices, processedCount, errorsCount) { + let title = ''; + let indicator = ''; + let message = ''; + + if (errorsCount === 0) { + title = __('Import Completed Successfully'); + indicator = 'green'; + message = __('All ') + totalInvoices + __(' sales invoices were imported successfully.'); + + frappe.msgprint({ + title: title, + indicator: indicator, + message: message + }); + } else { + title = errorsCount === totalInvoices ? __('Import Failed') : __('Import Completed with Errors'); + indicator = processedCount > 0 ? 'orange' : 'red'; + + message = '
'; + if (processedCount > 0) { + message += __('Successfully imported: ') + '' + processedCount + '' + __(' sales invoices') + '
'; + } + message += __('Failed: ') + '' + errorsCount + '' + __(' sales invoices'); + message += '
'; + + message += '
' + + '' + + '' + + '
'; + + const summaryDialog = frappe.msgprint({ + title: title, + indicator: indicator, + message: message + }); + + // Добавляем обработчики событий + setTimeout(function() { + $('#view-error-details-btn').off('click').on('click', function() { + summaryDialog.hide(); + SalesETaxes.errors.showDetails(); + }); + + $('#export-error-log-btn').off('click').on('click', function() { + SalesETaxes.errors.exportCSV(); + }); + }, 200); + } + } +}; + +// ======= МОДУЛЬ ИМПОРТА ИНВОЙСОВ ======= +SalesETaxes.import = { + // Показать диалог импорта из E-Taxes + showDialog: function() { + SalesETaxes.auth.checkAndProcess(function() { + const minDate = moment("2020-01-01", "YYYY-MM-DD"); + + const d = new frappe.ui.Dialog({ + title: __('Import sales invoice from E-Taxes'), + fields: [ + { + fieldname: 'date_range_section', + fieldtype: 'Section Break', + label: __('Period') + }, + { + fieldname: 'creationDateFrom', + fieldtype: 'Date', + label: __('Date from'), + default: moment().subtract(1, 'month').format('YYYY-MM-DD') + }, + { + fieldname: 'creationDateTo', + fieldtype: 'Date', + label: __('Date to'), + default: moment().format('YYYY-MM-DD') + }, + { + fieldname: 'settings_section', + fieldtype: 'Section Break', + label: __('Settings') + }, + { + fieldname: 'warehouse', + fieldtype: 'Link', + label: __('Delivery warehouse'), + options: 'Warehouse', + reqd: true, + get_query: function() { + return { + filters: { + 'is_group': 0, + 'disabled': 0 + } + }; + } + } + ], + primary_action_label: __('Search'), + primary_action: function() { + const values = d.get_values(); + + // Validate date range + const fromDateMoment = moment(values.creationDateFrom, "YYYY-MM-DD"); + const toDateMoment = moment(values.creationDateTo, "YYYY-MM-DD"); + const todayMoment = moment(); + + if (fromDateMoment.isBefore(minDate)) { + frappe.msgprint(__('From Date cannot be earlier than January 1, 2020')); + return; + } + + if (toDateMoment.isAfter(todayMoment)) { + frappe.msgprint(__('To Date cannot be later than today')); + return; + } + + if (toDateMoment.diff(fromDateMoment, 'days') > 366) { + frappe.msgprint(__('The date range cannot exceed one year.')); + return; + } + + if (!values.warehouse) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('Please select a delivery warehouse') + }); + return; + } + + const fromDate = moment(values.creationDateFrom).format('DD-MM-YYYY 00:00'); + const toDate = moment(values.creationDateTo).format('DD-MM-YYYY 23:59'); + + d.hide(); + SalesETaxes.import.loadInvoices(fromDate, toDate, values.warehouse); + } + }); + + d.show(); + }); + }, + + // Загрузить инвойсы с фильтрацией дубликатов + loadInvoices: function(fromDate, toDate, warehouse, accumulatedInvoices = [], offset = 0) { + SalesETaxes.cancelLoading = false; + $(document).off('progress-cancel.etaxes_sales_import'); + + if (offset === 0) { + SalesETaxes.dialogs.showLoading( + __('Loading E-Taxes Documents'), + __('Fetching E-Taxes sales invoices...'), + __('Please wait') + ); + } + + SalesETaxes.utils.getDefaultLogin(function(loginResponse) { + if (SalesETaxes.cancelLoading) return; + + if (loginResponse && loginResponse.found) { + const mainToken = loginResponse.main_token; + + if (!mainToken) { + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Authentication Error'), + indicator: 'red', + message: __('Authentication token not found. Please complete the authentication process in E-Taxes settings.') + }); + return; + } + + const maxCount = 200; + + frappe.call({ + method: 'invoice_az.sales_api.get_sales_invoices', + args: { + 'token': mainToken, + 'filters': JSON.stringify({ + "creationDateFrom": fromDate, + "creationDateTo": toDate, + "maxCount": maxCount, + "offset": offset + }) + }, + callback: function(r) { + if (SalesETaxes.cancelLoading) return; + + if (r.message && !r.message.error) { + const currentInvoices = r.message.data || r.message.invoices || []; + const hasMore = r.message.hasMore || false; + const allInvoices = accumulatedInvoices.concat(currentInvoices); + + if (hasMore && currentInvoices.length > 0) { + const newOffset = offset + currentInvoices.length; + SalesETaxes.dialogs.updateLoading( + null, + __('Fetching E-Taxes sales invoices...'), + __('Loaded ') + allInvoices.length + __(' so far...') + ); + SalesETaxes.import.loadInvoices(fromDate, toDate, warehouse, allInvoices, newOffset); + } else { + if (allInvoices.length > 0) { + SalesETaxes.dialogs.updateLoading( + null, + __('Processing ') + allInvoices.length + __(' invoices...'), + __('Checking for already imported documents') + ); + + SalesETaxes.import.filterDuplicates(allInvoices, mainToken, warehouse); + } else { + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Information'), + indicator: 'blue', + message: __('No sales invoices found for the specified period') + }); + } + } + } else if (r.message && r.message.error === 'unauthorized') { + SalesETaxes.dialogs.hide(); + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + SalesETaxes.auth.startProcess(function() { + SalesETaxes.import.loadInvoices(fromDate, toDate, warehouse); + }); + }, + function() { + frappe.show_alert({ + message: __('Authentication canceled. Operation cannot be completed.'), + indicator: 'red' + }, 5); + } + ); + } else { + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Error loading sales invoices') + }); + } + }, + error: function(xhr, status, error) { + console.error("Error fetching sales invoices:", error); + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Network Error'), + indicator: 'red', + message: __('Error fetching data from server: ') + (error || 'Unknown error') + }); + } + }); + } else { + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('E-Taxes authentication settings not found') + }); + } + }); + }, + + // Фильтрация дубликатов + filterDuplicates: function(allInvoices, token, warehouse) { + frappe.call({ + method: 'invoice_az.sales_api.get_etaxes_sales', + callback: function(etaxesR) { + try { + if (SalesETaxes.cancelLoading) return; + + const importedInvoices = {}; + + if (etaxesR.message && etaxesR.message.success && etaxesR.message.sales) { + etaxesR.message.sales.forEach(function(sale) { + if (sale.etaxes_id) { + const normId = String(sale.etaxes_id).trim(); + importedInvoices[normId] = true; + } + }); + } + + const filteredInvoices = []; + + for (let i = 0; i < allInvoices.length; i++) { + const invoice = allInvoices[i]; + const invoiceId = String(invoice.id).trim(); + + if (!importedInvoices.hasOwnProperty(invoiceId)) { + filteredInvoices.push(invoice); + } + } + + if (filteredInvoices.length > 0) { + SalesETaxes.import.showInvoiceSelection(filteredInvoices, token, warehouse); + } else { + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Information'), + indicator: 'blue', + message: __('No new sales invoices found for the specified period (all ' + + allInvoices.length + ' are already imported)') + }); + } + } catch (e) { + console.error("Error processing sales data:", e); + SalesETaxes.dialogs.hide(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('An error occurred while processing data: ') + e.message + }); + } + }, + error: function(err) { + console.error("Error fetching sales:", err); + SalesETaxes.import.showInvoiceSelection(allInvoices, token, warehouse); + frappe.show_alert({ + message: __('Failed to check for duplicates. Showing all sales invoices.'), + indicator: 'orange' + }, 5); + } + }); + }, + + // Показать диалог выбора инвойсов + showInvoiceSelection: function(invoices, token, warehouse) { + SalesETaxes.dialogs.hide(); + let invoiceTable = '
'; + invoiceTable += '' + + '' + + '' + + '' + + '' + + '' + + ''; + + invoices.forEach(function(invoice) { + const serialNumber = invoice.serialNumber || ''; + + let creationDate = ''; + if (invoice.createdAt) { + creationDate = moment(invoice.createdAt).format('DD.MM.YYYY'); + } else if (invoice.creationDate) { + creationDate = moment(invoice.creationDate).format('DD.MM.YYYY'); + } + + const receiverName = invoice.receiver ? invoice.receiver.name : (invoice.receiverName || ''); + const amount = invoice.totalAmount || invoice.amount || 0; + + invoiceTable += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + invoiceTable += '
' + __('Number') + '' + __('Date') + '' + __('Customer') + '' + __('Amount') + '
' + serialNumber + '' + creationDate + '' + receiverName + '' + SalesETaxes.utils.formatCurrency(amount) + '
'; + + // Компактная шапка: количество инвойсов + склад в одной строке. + // Заменяет два отдельных alert'а (верхний со счётчиком и нижний со складом, + // который раньше уезжал под таблицу и требовал скролла). + const headerHtml = + '
' + + '' + + __('Sales invoices found: ') + '' + invoices.length + '' + + '' + + '' + + __('Selected delivery warehouse: ') + '' + frappe.utils.escape_html(warehouse) + '' + + '' + + '
'; + + const d = new frappe.ui.Dialog({ + title: __('Select sales invoice'), + size: 'large', + fields: [ + { + fieldname: 'header_html', + fieldtype: 'HTML', + options: headerHtml + }, + { + fieldname: 'invoices_html', + fieldtype: 'HTML', + options: invoiceTable + } + ], + primary_action_label: __('Load selected'), + primary_action: function() { + const selectedInvoiceIds = []; + d.$wrapper.find('.select-sales-invoice:checked').each(function() { + selectedInvoiceIds.push($(this).data('id')); + }); + + if (selectedInvoiceIds.length === 0) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('No sales invoices selected') + }); + return; + } + + d.hide(); + SalesETaxes.cancelLoading = false; + SalesETaxes.import.loadSelectedInvoicesSocketIO(selectedInvoiceIds, token, warehouse); + }, + secondary_action_label: __('Cancel'), + secondary_action: function() { + d.hide(); + } + }); + + d.$wrapper.find('.modal-dialog').css({ + 'max-width': '80%', + 'width': '80%', + 'margin': '30px auto' + }); + + d.$wrapper.find('.modal-body').css({ + 'padding': '15px' + }); + + d.show(); + + // Обработчик для чекбокса "выбрать все" + d.$wrapper.find('.select-all-sales-invoices').on('change', function() { + const isChecked = $(this).prop('checked'); + d.$wrapper.find('.select-sales-invoice').prop('checked', isChecked); + }); + }, + + // Загрузка выбранных инвойсов + // Надёжное закрытие прогресс-бара. frappe.hide_progress() в jey_theme/Bootstrap + // не всегда убирает модал (он зависает, и summary/диалог накладывается сверху). + // Принудительно: modal('hide') + remove() + чистка осиротевшего backdrop. + _closeProgress: function() { + if (frappe.cur_progress) { + try { + frappe.cur_progress.$wrapper.modal('hide'); + frappe.cur_progress.$wrapper.remove(); + } catch (e) {} + frappe.cur_progress = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + + loadSelectedInvoices: function(invoiceIds, token, warehouse, processedCount = 0, currentIndex = 0) { + if (processedCount === 0 && currentIndex === 0) { + SalesETaxes.loadingErrors = []; + window.totalSalesInvoicesToProcess = invoiceIds.length; + } + + $(document).off('progress-cancel.loading_sales_invoices'); + + if (invoiceIds.length === 0) { + SalesETaxes.import._closeProgress(); + + const errorsCount = SalesETaxes.loadingErrors.length; + const totalInvoices = window.totalSalesInvoicesToProcess || processedCount; + + SalesETaxes.errors.showSummary(totalInvoices, processedCount, errorsCount); + window.totalSalesInvoicesToProcess = null; + + if (cur_list) { + cur_list.refresh(); + } + + return; + } + + if (SalesETaxes.cancelLoading) { + invoiceIds = []; + SalesETaxes.import._closeProgress(); + SalesETaxes.import.loadSelectedInvoices(invoiceIds, token, warehouse, processedCount, currentIndex); + return; + } + + const invoiceId = invoiceIds.shift(); + const isLastInvoice = invoiceIds.length === 0; + const totalToProcess = window.totalSalesInvoicesToProcess || (processedCount + invoiceIds.length + 1); + + currentIndex++; + + frappe.show_progress(__('Importing Sales Invoices'), currentIndex - 1, totalToProcess, + __('Loading sales invoice ') + currentIndex + __(' of ') + totalToProcess, null, true); + + $(document).on('progress-cancel.loading_sales_invoices', function() { + SalesETaxes.cancelLoading = true; + frappe.show_alert({ + message: __('Cancelling import... Finishing current sales invoice.'), + indicator: 'orange' + }, 3); + SalesETaxes.import._closeProgress(); + }); + + frappe.call({ + method: 'invoice_az.sales_api.get_sales_invoice_details', + args: { + 'token': token, + 'invoice_id': invoiceId + }, + callback: function(r) { + 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 || ''; + const receiverName = r.message.receiver ? r.message.receiver.name : ''; + const total = r.message.totalAmount || r.message.amount || 0; + + frappe.show_progress(__('Importing Sales Invoices'), currentIndex - 1, totalToProcess, + __('Importing sales invoice: ') + serialNumber); + + frappe.call({ + method: 'invoice_az.sales_api.import_sales_invoice_with_mapping', + args: { + 'invoice_data': r.message, + 'sales_order_name': null, + 'schedule_date': scheduleDate, + 'warehouse': warehouse // ДОБАВЛЕНО: передача warehouse + }, + callback: function(importR) { + SalesETaxes.import._handleImportResult(importR, r.message, invoiceId, scheduleDate, receiverName, total, + invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + }, + error: function(xhr, status, error) { + const errorMessage = 'Network error during import: ' + (error || 'Unknown network error'); + SalesETaxes.errors.add(r.message, 'Network Error', errorMessage, { + invoice_items: r.message.items || [], + xhr_status: xhr.status, + xhr_response: xhr.responseText + }); + + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }); + } else if (r.message && r.message.error === 'unauthorized') { + SalesETaxes.import._closeProgress(); + $(document).off('progress-cancel.loading_sales_invoices'); + + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + SalesETaxes.auth.startProcess(function() { + SalesETaxes.utils.getDefaultLogin(function(loginR) { + if (loginR && loginR.found && loginR.main_token) { + invoiceIds.unshift(invoiceId); + SalesETaxes.import.loadSelectedInvoices(invoiceIds, loginR.main_token, warehouse, processedCount, currentIndex - 1); + } else { + SalesETaxes.errors.add({id: invoiceId}, 'Authentication Error', + 'Failed to get token after authentication'); + + const errorsCount = SalesETaxes.loadingErrors.length; + SalesETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + } + }, false); + }); + }, + function() { + SalesETaxes.errors.add({id: invoiceId}, 'Authentication Cancelled', + 'User cancelled authentication process'); + + const errorsCount = SalesETaxes.loadingErrors.length; + SalesETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + } + ); + } else { + const errorMessage = r.message ? r.message.message : 'Failed to load sales invoice details'; + SalesETaxes.errors.add({id: invoiceId}, 'Sales Invoice Data Error', errorMessage, { + server_response: r.message + }); + + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }, + error: function(xhr, status, error) { + const errorMessage = 'Network error loading sales invoice details: ' + (error || 'Unknown network error'); + SalesETaxes.errors.add({id: invoiceId}, 'Network Error', errorMessage, { + xhr_status: xhr.status, + xhr_response: xhr.responseText + }); + + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }); + }, + + // Обработка результата импорта - ОБНОВЛЕНО для Sales Order + Sales Invoice + _handleImportResult: function(importR, invoiceMessage, invoiceId, scheduleDate, receiverName, total, + invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice) { + if (importR.message && importR.message.success) { + frappe.show_alert({ + message: __('Sales invoice successfully imported: ') + (invoiceMessage.serialNumber || ''), + indicator: 'green' + }, 3); + + // ИЗМЕНЕНО: теперь получаем sales_order и sales_invoice из ответа + const salesOrderName = importR.message.sales_order; + const salesInvoiceName = importR.message.sales_invoice; + + // ИЗМЕНЕНО: создание E-Taxes Sales и связывание с Sales Order происходит на сервере + // Поэтому просто увеличиваем счетчик и продолжаем + processedCount++; + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + + } else if (importR.message && (importR.message.unmatched_items || importR.message.unmatched_parties || importR.message.unmatched_units)) { + let errorType = ''; + const additionalData = { invoice_items: invoiceMessage.items || [] }; + + if (importR.message.unmatched_items) { + errorType = 'Unmapped Items'; + additionalData.unmatched_items = importR.message.unmatched_items.map(function(item, index) { + let itemName = ''; + let itemCode = ''; + + if (typeof item === 'string') { + itemName = item; + } else if (item && typeof item === 'object') { + itemName = item.name || item.productName || item.item_name || ''; + itemCode = item.code || item.itemId || item.item_code || ''; + } else { + if (invoiceMessage.items && invoiceMessage.items[index]) { + const originalItem = invoiceMessage.items[index]; + itemName = originalItem.productName || originalItem.name || ''; + itemCode = originalItem.itemId || originalItem.code || ''; + } + } + + if (!itemName) { + itemName = 'Item ' + (index + 1) + ' (name not available)'; + } + + return { + name: itemName, + code: itemCode + }; + }); + } else if (importR.message.unmatched_parties) { + errorType = 'Unmapped Parties'; + additionalData.unmatched_parties = importR.message.unmatched_parties.map(function(party, index) { + let partyName = ''; + let partyTin = ''; + let partyType = ''; + + if (typeof party === 'string') { + partyName = party; + } else if (party && typeof party === 'object') { + partyName = party.name || party.party_name || ''; + partyTin = party.tin || party.tax_id || ''; + partyType = party.type || party.party_type || ''; + } else { + if (invoiceMessage.receiver) { + partyName = invoiceMessage.receiver.name || ''; + partyTin = invoiceMessage.receiver.tin || ''; + partyType = 'Receiver'; + } + } + + if (!partyName) { + partyName = 'Party ' + (index + 1) + ' (name not available)'; + } + + return { + name: partyName, + tin: partyTin, + type: partyType + }; + }); + } else if (importR.message.unmatched_units) { + errorType = 'Unmapped Units'; + additionalData.unmatched_units = importR.message.unmatched_units.map(function(unit, index) { + let unitName = ''; + + if (typeof unit === 'string') { + unitName = unit; + } else if (unit && typeof unit === 'object') { + unitName = unit.name || unit.unit_name || ''; + } else { + if (invoiceMessage.items && invoiceMessage.items[index] && invoiceMessage.items[index].unit) { + unitName = invoiceMessage.items[index].unit; + } + } + + if (!unitName) { + unitName = 'Unit ' + (index + 1) + ' (name not available)'; + } + + return { + name: unitName + }; + }); + } + + additionalData.server_response = importR.message; + SalesETaxes.errors.add(invoiceMessage, errorType, importR.message.message, additionalData); + + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } else { + const errorMessage = importR.message ? importR.message.message : 'Unknown sales import error'; + const additionalData = { + invoice_items: invoiceMessage.items || [], + server_response: importR.message + }; + + if (importR.exc) { + additionalData.stack_trace = importR.exc; + } + + SalesETaxes.errors.add(invoiceMessage, 'Sales Import Error', errorMessage, additionalData); + SalesETaxes.import._continueOrFinish(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice); + } + }, + + // Продолжить или завершить обработку + _continueOrFinish: function(invoiceIds, token, warehouse, processedCount, currentIndex, isLastInvoice) { + if (isLastInvoice) { + SalesETaxes.import._closeProgress(); + $(document).off('progress-cancel.loading_sales_invoices'); + + const totalToProcess = window.totalSalesInvoicesToProcess || processedCount; + const errorsCount = SalesETaxes.loadingErrors.length; + SalesETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount); + + if (cur_list) { + cur_list.refresh(); + } + return; + } + + setTimeout(function() { + if (invoiceIds.length === 0) { + SalesETaxes.import._closeProgress(); + } + + SalesETaxes.import.loadSelectedInvoices(invoiceIds, token, warehouse, processedCount, currentIndex); + }, SalesETaxes.PROGRESS_UPDATE_DELAY); + }, + + // Socket.IO bulk import — all work happens on the server in a background job + loadSelectedInvoicesSocketIO: function(invoiceIds, token, warehouse) { + SalesETaxes.loadingErrors = []; + const total = invoiceIds.length; + // Защита от гонки realtime: 'complete' вызывает hide_progress() (обнуляет + // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который + // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. + let importFinished = false; + // Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении + // (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно. + const closeProgress = function() { + if (frappe.cur_progress) { + try { + frappe.cur_progress.$wrapper.modal('hide'); + frappe.cur_progress.$wrapper.remove(); + } catch (e) {} + frappe.cur_progress = null; + } + if ($('.modal:visible').length === 0) { + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }; + + // Progress + cancel are shown by the global Background Tasks widget + // (bottom-right). No blocking modal here; we keep only the complete + // subscription to show the final import summary. + frappe.realtime.off('sales_import_complete'); + frappe.realtime.on('sales_import_complete', function(data) { + importFinished = true; + frappe.realtime.off('sales_import_progress'); + frappe.realtime.off('sales_import_complete'); + closeProgress(); + + // Collect errors for the error panel + (data.errors || []).forEach(function(err) { + SalesETaxes.loadingErrors.push({ + invoice_id: err.invoice_id || 'Unknown', + error_type: err.unmatched_items ? 'Unmapped Items' + : err.unmatched_parties ? 'Unmapped Parties' + : err.unmatched_units ? 'Unmapped Units' + : 'Import Error', + error_message: err.error || 'Unknown error', + unmatched_items: err.unmatched_items, + unmatched_parties: err.unmatched_parties, + unmatched_units: err.unmatched_units + }); + }); + + const errorsCount = SalesETaxes.loadingErrors.length; + SalesETaxes.errors.showSummary(data.total, data.imported, errorsCount); + + if (cur_list) { + cur_list.refresh(); + } + }); + + // Enqueue the background job + frappe.call({ + method: 'invoice_az.sales_api.import_bulk_sales_invoices', + args: { + invoice_ids: JSON.stringify(invoiceIds), + token: token, + warehouse: warehouse + }, + callback: function(r) { + if (!r.message || !r.message.enqueued) { + frappe.realtime.off('sales_import_progress'); + frappe.realtime.off('sales_import_complete'); + closeProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to start import job') + }); + } + }, + error: function() { + frappe.realtime.off('sales_import_progress'); + frappe.realtime.off('sales_import_complete'); + closeProgress(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Network error starting import') + }); + } + }); + } +}; + +frappe.listview_settings['Sales Order'] = { + add_fields: ['customer', 'transaction_date', 'status', 'docstatus', 'is_taxes_doc', 'taxes_doc'], + + get_indicator: function(doc) { + if (doc.status === 'Closed') { + return [__('Closed'), 'green', 'status,=,Closed']; + } else if (doc.status === 'On Hold') { + return [__('On Hold'), 'orange', 'status,=,On Hold']; + } else if (doc.status === 'Delivered') { + return [__('Delivered'), 'green', 'status,=,Delivered']; + } else if (doc.docstatus == 1) { + return [__('To Deliver and Bill'), 'orange', 'docstatus,=,1']; + } else if (doc.docstatus == 0) { + return [__('Draft'), 'red', 'docstatus,=,0']; + } else if (doc.docstatus == 2) { + return [__('Cancelled'), 'grey', 'docstatus,=,2']; + } + }, + + onload: function(listview) { + // ДОБАВЛЕНО: кнопка импорта из E-Taxes для Sales Order + listview.page.add_inner_button(__('Import from E-Taxes'), function() { + SalesETaxes.import.showDialog(); + }, __('E-Taxes')); + + listview.page.add_inner_button(__('Open Settings'), function() { + frappe.set_route('Form', 'E-Taxes Settings'); + }, __('E-Taxes')); + + listview.columns.push({ + type: 'Check', + df: { + label: __('Tax Doc'), + fieldname: 'is_taxes_doc' + }, + width: 120 + }); + + listview.refresh(); + }, + + formatters: { + is_taxes_doc: function(value) { + return value ? + `` : + ``; + } + } +}; + +// Sales Invoice List - УБРАНО: кнопка импорта (теперь только в Sales Order) +frappe.listview_settings['Sales Invoice'] = { + add_fields: ['customer', 'posting_date', 'status', 'docstatus', 'is_taxes_doc'], + + onload: function(listview) { + // УБРАНО: кнопка импорта - теперь импорт только через Sales Order + + listview.columns.push({ + type: 'Check', + df: { + label: __('Tax Doc'), + fieldname: 'is_taxes_doc' + }, + width: 120 + }); + + listview.refresh(); + }, + + formatters: { + is_taxes_doc: function(value) { + return value ? + `` : + ``; + } + } +}; \ No newline at end of file diff --git a/invoice_az/hooks.py b/invoice_az/hooks.py index f12f03b..0aa44b1 100644 --- a/invoice_az/hooks.py +++ b/invoice_az/hooks.py @@ -10,7 +10,6 @@ doctype_js = { "Purchase Invoice": "client/purchase_invoice.js", "Sales Order": "client/sales_order.js", "Sales Invoice": "client/sales_invoice.js", - "Journal Entry": "client/journal_entry.js", "Supplier": "client/supplier.js", "Amas Employees": "client/amas_employees.js", "Employee": "client/employee.js", @@ -22,12 +21,12 @@ doctype_list_js = { "E-Taxes Suppliers": "client/e_taxes_suppliers_list.js", "E-Taxes Customers": "client/e_taxes_customers_list.js", "E-Taxes Unit": "client/e_taxes_unit_list.js", - "Purchase Order": "client/purchase_order.js", - "Purchase Invoice": "client/purchase_invoice.js", - "Sales Order": "client/sales_order.js", - "Sales Invoice": "client/sales_invoice.js", + "Purchase Order": "client/purchase_order_list.js", + "Purchase Invoice": "client/purchase_invoice_list.js", + "Sales Order": "client/sales_order_list.js", + "Sales Invoice": "client/sales_invoice_list.js", "Journal Entry": "client/journal_entry.js", - "Employee": "client/employee.js" + "Employee": "client/employee_list.js" } # Хуки для добавления обработчиков событий Purchase Order