From 423549e084b454a2edcf95e2ad555a1d6abb86c8 Mon Sep 17 00:00:00 2001 From: Saif Ur Rehman Date: Sat, 8 Sep 2018 15:48:55 +0500 Subject: [PATCH 01/12] Fixed PDC data in AR/AP report: -Get PDC date from posting_date instead of reference date since posting_date is the date at which the post dated cheque can be cleared -Get PDC data from submitted documents only --- .../accounts_receivable/accounts_receivable.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index abcfa73a07..fcd9f27282 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -446,14 +446,14 @@ def get_pdc_details(party_type, report_date): for pdc in frappe.db.sql(""" select pref.reference_name as invoice_no, pent.party, pent.party_type, - max(pent.reference_date) as pdc_date, sum(ifnull(pref.allocated_amount,0)) as pdc_amount, + max(pent.posting_date) as pdc_date, sum(ifnull(pref.allocated_amount,0)) as pdc_amount, GROUP_CONCAT(pent.reference_no SEPARATOR ', ') as pdc_ref from `tabPayment Entry` as pent inner join `tabPayment Entry Reference` as pref on (pref.parent = pent.name) where - pent.docstatus < 2 and pent.reference_date >= %s + pent.docstatus=1 and pent.posting_date > %s and pent.party_type = %s group by pent.party, pref.reference_name""", (report_date, party_type), as_dict=1): pdc_details.setdefault((pdc.invoice_no, pdc.party), pdc) @@ -467,18 +467,23 @@ def get_pdc_details(party_type, report_date): for pdc in frappe.db.sql(""" select jea.reference_name as invoice_no, jea.party, jea.party_type, - max(je.cheque_date) as pdc_date, sum(ifnull({0},0)) as pdc_amount, + max(je.posting_date) as pdc_date, sum(ifnull({0},0)) as pdc_amount, GROUP_CONCAT(je.cheque_no SEPARATOR ', ') as pdc_ref from `tabJournal Entry` as je inner join `tabJournal Entry Account` as jea on (jea.parent = je.name) where - je.docstatus < 2 and je.cheque_date >= %s + je.docstatus=1 and je.posting_date > %s and jea.party_type = %s group by jea.party, jea.reference_name""".format(amount_field), (report_date, party_type), as_dict=1): if (pdc.invoice_no, pdc.party) in pdc_details: - pdc_details[(pdc.invoice_no, pdc.party)]["pdc_amount"] += pdc.pdc_amount + key = (pdc.invoice_no, pdc.party) + pdc_details[key]["pdc_amount"] += pdc.pdc_amount + if pdc.pdc_ref: + pdc_details[key]["pdc_ref"] += ", " + pdc.pdc_ref + if pdc.pdc_date: + pdc_details[key]["pdc_date"] = max(pdc_details[key]["pdc_date"], pdc.pdc_date) else: pdc_details.setdefault((pdc.invoice_no, pdc.party), pdc) From a5a926967bb1d7f4b41d0221ef25ece92ac1d8e8 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 16 Oct 2018 11:27:23 +0530 Subject: [PATCH 02/12] Update accounts_receivable.py --- .../report/accounts_receivable/accounts_receivable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index fcd9f27282..a212a566f6 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -453,7 +453,7 @@ def get_pdc_details(party_type, report_date): on (pref.parent = pent.name) where - pent.docstatus=1 and pent.posting_date > %s + pent.docstatus < 2 and pent.posting_date > %s and pent.party_type = %s group by pent.party, pref.reference_name""", (report_date, party_type), as_dict=1): pdc_details.setdefault((pdc.invoice_no, pdc.party), pdc) @@ -474,7 +474,7 @@ def get_pdc_details(party_type, report_date): on (jea.parent = je.name) where - je.docstatus=1 and je.posting_date > %s + je.docstatus < 2 and je.posting_date > %s and jea.party_type = %s group by jea.party, jea.reference_name""".format(amount_field), (report_date, party_type), as_dict=1): if (pdc.invoice_no, pdc.party) in pdc_details: From 007fbfbfb7957ba46007162d0a5af33b1316ba40 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Wed, 17 Oct 2018 17:50:00 +0530 Subject: [PATCH 03/12] [Feature] ERPNext Quickbooks Migrator (#15718) * Create QuickBooks Connector Single DocType * Create interface for user authorization and obtaining authorization code * Obtain Access token using authorization code * Fetch a random customer * Schedule fetching as a background job * Save fetched customer * Fetch all customers, take care of pagination * Save fetched customers * Create a custom field for storing Quickbooks ID * Don't save already saved customers * Commit every successful insert * No need of allow_guest=True * Fetch Items as well * Store Customer Addresses as well. * Remove redundant custom field creation code * Stupid refactoring * Some more refactoring * Fetch and Save Suppliers as well * Save accounts. Really a hack as of now. * Fetch and save invoices, Take care of child items as well * Fetch Taxes as well * Set currency on Sales Invoice * Correctly Link Items, Handle markups * Don't enqueue, Need to wait forever for testing sometimes * Set margin rate correctly * Correct fieldname is tax_amount not amount * Fetch with maximum possible batch size * Don't print unnecessary info * Fetch Journal Entries * Fetch Purchase Invoices (Bill) * Don't perform reauthentication if you already have an access_token * Don't need those print statements anymore * Refresh access_token if request fails * Fetch Payment Entry(Payment) * Map QB items to ERPNext rather than other way around * Don't need a huge comment block as of now * Fetch Payment Entries against Purchase Invoices (BillPayment) * Rename Quickbooks Connector to Migrator * Make oauth settings configurable * Make company configurable * Make default accounts configurable * Fetch accounts and other masters separately * Show realtime progress * Check if entries exist before attempting insert * Suppress other annoying messages * Tracebacks are good for health * Don't want rounded total * Don't call fetch after authorization (Need to somehow separate these) * Make credit_to account payable * When generating payment entry mention bank_account as well * Cleanup * Add custom buttons and reflect current state in form ui * Show necessary fields on form depending on the current state * Cleanup * Minor fixes * Set income and expense account on items. * Set currency for Account. * Stupid VSCode * Remove redundant code * Check for existing Payment and BillPayment correctly * Cache API response for faster development * Don't maintain stock for now, Seems to solve the issue with 'Stock Received but not billed' * Cleanup * Add methods to remove inserted data completely (Development) * Don't commit in every iteration * Set account head based on TaxRate in Sales Invoice * Fetch and cache TaxCode * Add methods to fetch TaxCode and TaxRate from cache * Set item wise tax breakup, Don't use Actual Tax * Use both TaxRateLists * Set Itemwise tax on Purchase Invoice as well * Set bank_amount, while creating payment entries * Remove print statements * Add Shipping in taxes child table of Sales Invoice * Set posting date on Payment Entries against Invoices * Fetch and save expenses as Journal Entries * Fetch and save Deposits as Journal Entries * Fetch and Save Credit and Debit Notes * Fetch and save SalesReceipt * Record Shipping as Item instead of Tax * Minor Fixes * Fetch Advance Payments * Set account_type during Account creation * Choose receivable account in Invoice based on currency * Remove receivable_account field * Account type is already set during account creation * Set receivable account based on currency in Sales Receipt and Credit Memo * Set receivable account on Customer * Save Company Preferences * Set shipping_account based on company preferences * Set cost center in Sales Invoice Items and Taxes * Make Account name unique in case of duplicate account name * Fetch and Save TaxPayment as Journal Entry * Record UndepositedFunds Account in cache if found * Credit UndepositedFunds account when mentioned in Deposit * Debit Cashback account as well if mentioned in Deposit * Set currency conversion rate * Set is_group field correctly for accounts * Preserve account hierarchy * Use quickbooks_id and company together for filtering * Make new custom field 'company' on Supplier Customer and Item * Use comapny field for filtering Customer, Item, Supplier * Shipping Account is only available if shipping is enabled for comapny * Invoice might not have TxnTaxDetail.TaxLine * Lookup TaxRate account correctly * TaxCode might not have SalesTaxRateList PurchaseTaxRateList or Both * TaxCode might not have SalesTaxRateList and PurchaseTaxRateList * Remove unnecessary UI elements * Allow multiple companies to have customers with same name * Refactor account_type * Create a leaf an account for every group account * Item and Tax list creation needs more information * Supports discount in Invoice * Save Invoice as Journal Entry when itemwise details are unavailable * Credit=True represents a refund rather than charge * Mode of Payment not synced, setting as Cash for now * Tax code may also be set for an entire invoice * Fetch General Ledger Report correctly * Refactor fetching entries from General Ledger Report * Tax Payment is the same as Sales Tax Payment and Purchase Tax Payment * append_number_if_name_exists is stupid, Reimplementing * Don't guess credit/debit for Tax Payment and Advanced Payment * Bill Payment shouldn't assume full payment * Save Payment as Journal Entry * Make Client ID and Client Secret Default * Remove unused python imports * removed are_accounts_synced() * Don't store translated values in database * Endpoints as a field * Major refactor - functions to methods * Major Refactor Functions to Methods * Major Refactor Fix Stuff * Minor Changes * Save Refund As well * Minor Refactor (SI varations to SI, PI variations to PI) * Record Inventory Qty Adjust Entries * QBM Final Touches * Make changes suggested in review --- .../doctype/quickbooks_migrator/__init__.py | 0 .../quickbooks_migrator.js | 71 + .../quickbooks_migrator.json | 843 +++++++++++ .../quickbooks_migrator.py | 1274 +++++++++++++++++ .../test_quickbooks_migrator.js | 23 + .../test_quickbooks_migrator.py | 10 + 6 files changed, 2221 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js new file mode 100644 index 0000000000..bfffcc5153 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js @@ -0,0 +1,71 @@ +// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('QuickBooks Migrator', { + connect: function(frm) { + // OAuth requires user intervention to provide application access permissionsto requested scope + // Here we open a new window and redirect user to the authorization url. + // After user grants us permission to access. We will set authorization details on this doc which will force refresh. + window.open(frm.doc.authorization_url) + }, + fetch_data: function(frm) { + frm.call("migrate") + }, + onload: function(frm) { + frm.trigger("set_indicator") + var domain = frappe.urllib.get_base_url() + var redirect_url = `${domain}/api/method/erpnext.erpnext_integrations.doctype.quickbooks_migrator.quickbooks_migrator.callback` + if (frm.doc.redirect_url != redirect_url) { + frm.set_value("redirect_url", redirect_url) + } + // Instead of changing percentage width and message of single progress bar + // Show a different porgress bar for every action after some time remove the finished progress bar + // Former approach causes the progress bar to dance back and forth. + frm.trigger("set_indicator") + frappe.realtime.on("quickbooks_progress_update", function (data) { + frm.dashboard.show_progress(data.message, (data.count / data.total) * 100, data.message) + if (data.count == data.total) { + window.setTimeout( function(message) {frm.dashboard.hide_progress(message)}, 1500, data.messsage) + } + }) + }, + refresh: function(frm) { + frm.trigger("set_indicator") + if (!frm.doc.access_token) { + // Unset access_token signifies that we don't have enough information to connect to quickbooks api and fetch data + if (frm.doc.authorization_url) { + frm.add_custom_button(__("Connect to Quickbooks"), function () { + frm.trigger("connect") + }); + } + } + if (frm.doc.access_token) { + // If we have access_token that means we also have refresh_token we don't need user intervention anymore + // All we need now is a Company from erpnext + frm.remove_custom_button(__("Connect to Quickbooks")) + + frm.toggle_display("company_settings", 1) + frm.set_df_property("company", "reqd", 1) + if (frm.doc.company) { + frm.add_custom_button(__("Fetch Data"), function () { + frm.trigger("fetch_data") + }); + } + } + }, + set_indicator: function(frm) { + var indicator_map = { + "Connecting to QuickBooks": [__("Connecting to QuickBooks"), "orange"], + "Connected to QuickBooks": [__("Connected to QuickBooks"), "green"], + "In Progress": [__("In Progress"), "orange"], + "Complete": [__("Complete"), "green"], + "Failed": [__("Failed"), "red"], + } + if (frm.doc.status) { + var indicator = indicator_map[frm.doc.status] + var label = indicator[0] + var color = indicator[1] + frm.page.set_indicator(label, color) + } + }, +}); diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json new file mode 100644 index 0000000000..b156006e28 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json @@ -0,0 +1,843 @@ +{ + "allow_copy": 0, + "allow_events_in_timeline": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 1, + "creation": "2018-07-10 14:48:16.757030", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Status", + "length": 0, + "no_copy": 0, + "options": "Connecting to QuickBooks\nConnected to QuickBooks\nIn Progress\nComplete\nFailed", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": "eval:doc.client_id && doc.client_secret && doc.redirect_url", + "columns": 0, + "fieldname": "application_settings", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Application Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "client_id", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Client ID", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "redirect_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Redirect URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + "fieldname": "token_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Token Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "application_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "client_secret", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Client Secret", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "com.intuit.quickbooks.accounting", + "fieldname": "scope", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Scope", + "length": 0, + "no_copy": 0, + "options": "", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://quickbooks.api.intuit.com/v3", + "fieldname": "api_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "API Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "columns": 0, + "fieldname": "authorization_settings", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://appcenter.intuit.com/connect/oauth2", + "fieldname": "authorization_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "refresh_token", + "fieldtype": "Small Text", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Refresh Token", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "code", + "fieldtype": "Data", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Code", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "authorization_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "authorization_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "access_token", + "fieldtype": "Small Text", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Access Token", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "quickbooks_company_id", + "fieldtype": "Data", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Quickbooks Company ID", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company_settings", + "fieldtype": "Section Break", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Company Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company", + "fieldtype": "Link", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Company", + "length": 0, + "no_copy": 0, + "options": "Company", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_shipping_account", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Shipping Account", + "length": 0, + "no_copy": 0, + "options": "Account", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_warehouse", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Warehouse", + "length": 0, + "no_copy": 0, + "options": "Warehouse", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_cost_center", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Cost Center", + "length": 0, + "no_copy": 0, + "options": "Cost Center", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "undeposited_funds_account", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Undeposited Funds Account", + "length": 0, + "no_copy": 0, + "options": "Account", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 0, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2018-10-17 03:12:53.506229", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "QuickBooks Migrator", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "cancel": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 0, + "track_seen": 0, + "track_views": 0 +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py new file mode 100644 index 0000000000..29a1a2b0bb --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py @@ -0,0 +1,1274 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from requests_oauthlib import OAuth2Session +import json, requests +from erpnext import encode_company_abbr + +# QuickBooks requires a redirect URL, User will be redirect to this URL +# This will be a GET request +# Request parameters will have two parameters `code` and `realmId` +# `code` is required to acquire refresh_token and access_token +# `realmId` is the QuickBooks Company ID. It is Needed to actually fetch data. +@frappe.whitelist() +def callback(*args, **kwargs): + migrator = frappe.get_doc("QuickBooks Migrator") + migrator.set_indicator("Connecting to QuickBooks") + migrator.code = kwargs.get("code") + migrator.quickbooks_company_id = kwargs.get("realmId") + migrator.save() + migrator.get_tokens() + frappe.db.commit() + migrator.set_indicator("Connected to QuickBooks") + # We need this page to automatically close afterwards + frappe.respond_as_web_page("Quickbooks Authentication", html="") + + +class QuickBooksMigrator(Document): + def __init__(self, *args, **kwargs): + super(QuickBooksMigrator, self).__init__(*args, **kwargs) + from pprint import pprint + self.oauth = OAuth2Session( + client_id=self.client_id, + redirect_uri=self.redirect_url, + scope=self.scope + ) + if not self.authorization_url and self.authorization_endpoint: + self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] + + + def on_update(self): + if self.company: + # We need a Cost Center corresponding to the selected erpnext Company + self.default_cost_center = frappe.db.get_value('Company', self.company, 'cost_center') + self.default_warehouse = frappe.get_all('Warehouse', filters={"company": self.company, "is_group": 0})[0]["name"] + if self.authorization_endpoint: + self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] + + + def migrate(self): + frappe.enqueue_doc("QuickBooks Migrator", "QuickBooks Migrator", "_migrate", queue="long") + + + def _migrate(self): + try: + self.set_indicator("In Progress") + # Add quickbooks_id field to every document so that we can lookup by Id reference + # provided by documents in API responses. + # Also add a company field to Customer Supplier and Item + self._make_custom_fields() + + self._migrate_accounts() + + # Some Quickbooks Entities like Advance Payment, Payment aren't available firectly from API + # Sales Invoice also sometimes needs to be saved as a Journal Entry + # (When Item table is not present, This appens when Invoice is attached with a "StatementCharge" "ReimburseCharge + # Details of both of these cannot be fetched from API) + # Their GL entries need to be generated from GeneralLedger Report. + self._fetch_general_ledger() + + # QuickBooks data can have transactions that do not fall in existing fiscal years in ERPNext + self._create_fiscal_years() + + self._allow_fraction_in_unit() + + # Following entities are directly available from API + # Invoice can be an exception sometimes though (as explained above). + entities_for_normal_transform = [ + "Customer", "Item", "Vendor", + "Preferences", + "JournalEntry", "Purchase", "Deposit", + "Invoice", "CreditMemo", "SalesReceipt", "RefundReceipt", + "Bill", "VendorCredit", + "Payment", "BillPayment", + ] + for entity in entities_for_normal_transform: + self._migrate_entries(entity) + + # Following entries are not available directly from API, Need to be regenrated from GeneralLedger Report + entities_for_gl_transform = ["Advance Payment", "Tax Payment", "Sales Tax Payment", "Purchase Tax Payment", "Inventory Qty Adjust"] + for entity in entities_for_gl_transform: + self._migrate_entries_from_gl(entity) + self.set_indicator("Complete") + except Exception as e: + self.set_indicator("Failed") + self._log_error(e) + + frappe.db.commit() + + + def get_tokens(self): + token = self.oauth.fetch_token( + token_url=self.token_endpoint, + client_secret=self.client_secret, + code=self.code + ) + self.access_token = token["access_token"] + self.refresh_token = token["refresh_token"] + self.save() + + + def _refresh_tokens(self): + token = self.oauth.refresh_token( + token_url=self.token_endpoint, + client_id=self.client_id, + refresh_token=self.refresh_token, + client_secret=self.client_secret, + code=self.code + ) + self.access_token = token["access_token"] + self.refresh_token = token["refresh_token"] + self.save() + + + def _make_custom_fields(self): + doctypes_for_quickbooks_id_field = ["Account", "Customer", "Address", "Item", "Supplier", "Sales Invoice", "Journal Entry", "Purchase Invoice"] + for doctype in doctypes_for_quickbooks_id_field: + self._make_custom_quickbooks_id_field(doctype) + + doctypes_for_company_field = ["Customer", "Item", "Supplier"] + for doctype in doctypes_for_company_field: + self._make_custom_company_field(doctype) + + frappe.db.commit() + + + def _make_custom_quickbooks_id_field(self, doctype): + if not frappe.get_meta(doctype).has_field("quickbooks_id"): + frappe.get_doc({ + "doctype": "Custom Field", + "label": "QuickBooks ID", + "dt": doctype, + "fieldname": "quickbooks_id", + "fieldtype": "Data", + }).insert() + + + def _make_custom_company_field(self, doctype): + if not frappe.get_meta(doctype).has_field("company"): + frappe.get_doc({ + "doctype": "Custom Field", + "label": "Company", + "dt": doctype, + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + }).insert() + + + def _migrate_accounts(self): + self._make_root_accounts() + for entity in ["Account", "TaxRate", "TaxCode"]: + self._migrate_entries(entity) + + + def _make_root_accounts(self): + roots = ["Asset", "Equity", "Expense", "Liability", "Income"] + for root in roots: + try: + if not frappe.db.exists({"doctype": "Account", "name": encode_company_abbr("{} - QB".format(root), self.company), "company": self.company}): + frappe.get_doc({ + "doctype": "Account", + "account_name": "{} - QB".format(root), + "root_type": root, + "is_group": "1", + "company": self.company, + }).insert(ignore_mandatory=True) + except Exception as e: + self._log_error(e, root) + frappe.db.commit() + + + def _migrate_entries(self, entity): + try: + query_uri = "{}/company/{}/query".format( + self.api_endpoint, + self.quickbooks_company_id, + ) + max_result_count = 1000 + # Count number of entries + response = self._get(query_uri, + params={ + "query": """SELECT COUNT(*) FROM {}""".format(entity) + } + ) + entry_count = response.json()["QueryResponse"]["totalCount"] + + # fetch pages and accumulate + entries = [] + for start_position in range(1, entry_count + 1, max_result_count): + response = self._get(query_uri, + params={ + "query": """SELECT * FROM {} STARTPOSITION {} MAXRESULTS {}""".format( + entity, start_position, max_result_count + ) + } + ) + entries.extend(response.json()["QueryResponse"][entity]) + entries = self._preprocess_entries(entity, entries) + self._save_entries(entity, entries) + except Exception as e: + self._log_error(e, response.text) + + + def _fetch_general_ledger(self): + try: + query_uri = "{}/company/{}/reports/GeneralLedger".format(self.api_endpoint ,self.quickbooks_company_id) + response = self._get(query_uri, + params={ + "columns": ",".join(["tx_date", "txn_type", "credit_amt", "debt_amt"]), + "date_macro": "All", + "minorversion": 3, + } + ) + self.gl_entries = {} + for section in response.json()["Rows"]["Row"]: + if section["type"] == "Section": + self._get_gl_entries_from_section(section) + self.general_ledger = {} + for account in self.gl_entries.values(): + for line in account: + type_dict = self.general_ledger.setdefault(line["type"], {}) + if line["id"] not in type_dict: + type_dict[line["id"]] = { + "id": line["id"], + "date": line["date"], + "lines": [], + } + type_dict[line["id"]]["lines"].append(line) + except Exception as e: + self._log_error(e, response.text) + + + def _create_fiscal_years(self): + try: + # Assumes that exactly one fiscal year has been created so far + # Creates fiscal years till oldest ledger entry date is covered + from frappe.utils.data import add_years, getdate + from itertools import chain + smallest_ledger_entry_date = getdate(min(entry["date"] for entry in chain(*self.gl_entries.values()) if entry["date"])) + oldest_fiscal_year = frappe.get_all("Fiscal Year", + fields=["year_start_date", "year_end_date"], + order_by="year_start_date" + )[0] + # Keep on creating fiscal years + # until smallest_ledger_entry_date is no longer smaller than the oldest fiscal year's start date + while smallest_ledger_entry_date < oldest_fiscal_year.year_start_date: + new_fiscal_year = frappe.get_doc({"doctype": "Fiscal Year"}) + new_fiscal_year.year_start_date = add_years(oldest_fiscal_year.year_start_date, -1) + new_fiscal_year.year_end_date = add_years(oldest_fiscal_year.year_end_date, -1) + if new_fiscal_year.year_start_date.year == new_fiscal_year.year_end_date.year: + new_fiscal_year.year = new_fiscal_year.year_start_date.year + else: + new_fiscal_year.year = "{}-{}".format(new_fiscal_year.year_start_date.year, new_fiscal_year.year_end_date.year) + new_fiscal_year.save() + oldest_fiscal_year = new_fiscal_year + + frappe.db.commit() + except Exception as e: + self._log_error(e) + + + def _migrate_entries_from_gl(self, entity): + if entity in self.general_ledger: + self._save_entries(entity, self.general_ledger[entity].values()) + + + def _save_entries(self, entity, entries): + entity_method_map = { + "Account": self._save_account, + "TaxRate": self._save_tax_rate, + "TaxCode": self._save_tax_code, + + "Preferences": self._save_preference, + + "Customer": self._save_customer, + "Item": self._save_item, + "Vendor": self._save_vendor, + + "Invoice": self._save_invoice, + "CreditMemo": self._save_credit_memo, + "SalesReceipt": self._save_sales_receipt, + "RefundReceipt": self._save_refund_receipt, + + "JournalEntry": self._save_journal_entry, + + "Bill": self._save_bill, + "VendorCredit": self._save_vendor_credit, + + "Payment": self._save_payment, + "BillPayment": self._save_bill_payment, + + "Purchase": self._save_purchase, + "Deposit": self._save_deposit, + + "Advance Payment": self._save_advance_payment, + "Tax Payment": self._save_tax_payment, + "Sales Tax Payment": self._save_tax_payment, + "Purchase Tax Payment": self._save_tax_payment, + "Inventory Qty Adjust": self._save_inventory_qty_adjust, + } + total = len(entries) + for index, entry in enumerate(entries, start=1): + self._publish({"event": "progress", "message": _("Saving {0}").format(entity), "count": index, "total": total}) + entity_method_map[entity](entry) + frappe.db.commit() + + + def _preprocess_entries(self, entity, entries): + entity_method_map = { + "Account": self._preprocess_accounts, + "TaxRate": self._preprocess_tax_rates, + "TaxCode": self._preprocess_tax_codes, + } + preprocessor = entity_method_map.get(entity) + if preprocessor: + entries = preprocessor(entries) + return entries + + + def _get_gl_entries_from_section(self, section, account=None): + if "Header" in section: + if "id" in section["Header"]["ColData"][0]: + account = self._get_account_name_by_id(section["Header"]["ColData"][0]["id"]) + elif "value" in section["Header"]["ColData"][0] and section["Header"]["ColData"][0]["value"]: + # For some reason during migrating UK company, account id is not available. + # preprocess_accounts retains name:account mapping in self.accounts + # This mapping can then be used to obtain quickbooks_id for correspondong account + # Rest is trivial + + # Some Lines in General Leder Report are shown under Not Specified + # These should be skipped + if section["Header"]["ColData"][0]["value"] == "Not Specified": + return + account_id = self.accounts[section["Header"]["ColData"][0]["value"]]["Id"] + account = self._get_account_name_by_id(account_id) + entries = [] + for row in section["Rows"]["Row"]: + if row["type"] == "Data": + data = row["ColData"] + entries.append({ + "account": account, + "date": data[0]["value"], + "type": data[1]["value"], + "id": data[1].get("id"), + "credit": frappe.utils.flt(data[2]["value"]), + "debit": frappe.utils.flt(data[3]["value"]), + }) + if row["type"] == "Section": + self._get_gl_entries_from_section(row, account) + self.gl_entries.setdefault(account, []).extend(entries) + + + def _preprocess_accounts(self, accounts): + self.accounts = {account["Name"]: account for account in accounts} + for account in accounts: + if any(acc["SubAccount"] and acc["ParentRef"]["value"] == account["Id"] for acc in accounts): + account["is_group"] = 1 + else: + account["is_group"] = 0 + return sorted(accounts, key=lambda account: int(account["Id"])) + + + def _save_account(self, account): + mapping = { + "Bank": "Asset", + "Other Current Asset": "Asset", + "Fixed Asset": "Asset", + "Other Asset": "Asset", + "Accounts Receivable": "Asset", + + "Equity": "Equity", + + "Expense": "Expense", + "Other Expense": "Expense", + "Cost of Goods Sold": "Expense", + + "Accounts Payable": "Liability", + "Credit Card": "Liability", + "Long Term Liability": "Liability", + "Other Current Liability": "Liability", + + "Income": "Income", + "Other Income": "Income", + } + # Map Quickbooks Account Types to ERPNext root_accunts and and root_type + try: + if not frappe.db.exists({"doctype": "Account", "quickbooks_id": account["Id"], "company": self.company}): + is_child = account["SubAccount"] + is_group = account["is_group"] + # Create Two Accounts for every Group Account + if is_group: + account_id = "Group - {}".format(account["Id"]) + else: + account_id = account["Id"] + + if is_child: + parent_account = self._get_account_name_by_id("Group - {}".format(account["ParentRef"]["value"])) + else: + parent_account = encode_company_abbr("{} - QB".format(mapping[account["AccountType"]]), self.company) + + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": account_id, + "account_name": self._get_unique_account_name(account["Name"]), + "root_type": mapping[account["AccountType"]], + "account_type": self._get_account_type(account), + "account_currency": account["CurrencyRef"]["value"], + "parent_account": parent_account, + "is_group": is_group, + "company": self.company, + }).insert() + + if is_group: + # Create a Leaf account corresponding to the group account + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": account["Id"], + "account_name": self._get_unique_account_name(account["Name"]), + "root_type": mapping[account["AccountType"]], + "account_type": self._get_account_type(account), + "account_currency": account["CurrencyRef"]["value"], + "parent_account": self._get_account_name_by_id(account_id), + "is_group": 0, + "company": self.company, + }).insert() + if account.get("AccountSubType") == "UndepositedFunds": + self.undeposited_funds_account = self._get_account_name_by_id(account["Id"]) + self.save() + except Exception as e: + self._log_error(e, account) + + + def _get_account_type(self, account): + account_subtype_mapping = {"UndepositedFunds": "Cash"} + account_type = account_subtype_mapping.get(account.get("AccountSubType")) + if account_type is None: + account_type_mapping = {"Accounts Payable": "Payable", "Accounts Receivable": "Receivable", "Bank": "Bank", "Credit Card": "Bank"} + account_type = account_type_mapping.get(account["AccountType"]) + return account_type + + + def _preprocess_tax_rates(self, tax_rates): + self.tax_rates = {tax_rate["Id"]: tax_rate for tax_rate in tax_rates} + return tax_rates + + + def _save_tax_rate(self, tax_rate): + try: + if not frappe.db.exists({"doctype": "Account", "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), "company": self.company}): + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), + "account_name": "{} - QB".format(tax_rate["Name"]), + "root_type": "Liability", + "parent_account": encode_company_abbr("{} - QB".format("Liability"), self.company), + "is_group": "0", + "company": self.company, + }).insert() + except Exception as e: + self._log_error(e, tax_rate) + + + def _preprocess_tax_codes(self, tax_codes): + self.tax_codes = {tax_code["Id"]: tax_code for tax_code in tax_codes} + return tax_codes + + + def _save_tax_code(self, tax_code): + pass + + + def _save_customer(self, customer): + try: + if not frappe.db.exists({"doctype": "Customer", "quickbooks_id": customer["Id"], "company": self.company}): + try: + receivable_account = frappe.get_all("Account", filters={ + "account_type": "Receivable", + "account_currency": customer["CurrencyRef"]["value"], + "company": self.company, + })[0]["name"] + except Exception as e: + receivable_account = None + erpcustomer = frappe.get_doc({ + "doctype": "Customer", + "quickbooks_id": customer["Id"], + "customer_name" : encode_company_abbr(customer["DisplayName"], self.company), + "customer_type" : "Individual", + "customer_group" : "Commercial", + "default_currency": customer["CurrencyRef"]["value"], + "accounts": [{"company": self.company, "account": receivable_account}], + "territory" : "All Territories", + "company": self.company, + }).insert() + if "BillAddr" in customer: + self._create_address(erpcustomer, "Customer", customer["BillAddr"], "Billing") + if "ShipAddr" in customer: + self._create_address(erpcustomer, "Customer", customer["ShipAddr"], "Shipping") + except Exception as e: + self._log_error(e, customer) + + + def _save_item(self, item): + try: + if not frappe.db.exists({"doctype": "Item", "quickbooks_id": item["Id"], "company": self.company}): + if item["Type"] in ("Service", "Inventory"): + item_dict = { + "doctype": "Item", + "quickbooks_id": item["Id"], + "item_code" : encode_company_abbr(item["Name"], self.company), + "stock_uom": "Unit", + "is_stock_item": 0, + "item_group": "All Item Groups", + "company": self.company, + "item_defaults": [{"company": self.company, "default_warehouse": self.default_warehouse}] + } + if "ExpenseAccountRef" in item: + expense_account = self._get_account_name_by_id(item["ExpenseAccountRef"]["value"]) + item_dict["item_defaults"][0]["expense_account"] = expense_account + if "IncomeAccountRef" in item: + income_account = self._get_account_name_by_id(item["IncomeAccountRef"]["value"]) + item_dict["item_defaults"][0]["income_account"] = income_account + frappe.get_doc(item_dict).insert() + except Exception as e: + self._log_error(e, item) + + + def _allow_fraction_in_unit(self): + frappe.db.set_value("UOM", "Unit", "must_be_whole_number", 0) + + + def _save_vendor(self, vendor): + try: + if not frappe.db.exists({"doctype": "Supplier", "quickbooks_id": vendor["Id"], "company": self.company}): + erpsupplier = frappe.get_doc({ + "doctype": "Supplier", + "quickbooks_id": vendor["Id"], + "supplier_name" : encode_company_abbr(vendor["DisplayName"], self.company), + "supplier_group" : "All Supplier Groups", + "company": self.company, + }).insert() + if "BillAddr" in vendor: + self._create_address(erpsupplier, "Supplier", vendor["BillAddr"], "Billing") + if "ShipAddr" in vendor: + self._create_address(erpsupplier, "Supplier",vendor["ShipAddr"], "Shipping") + except Exception as e: + self._log_error(e) + + + def _save_preference(self, preference): + try: + if preference["SalesFormsPrefs"]["AllowShipping"]: + default_shipping_account_id = preference["SalesFormsPrefs"]["DefaultShippingAccount"] + self.default_shipping_account = self._get_account_name_by_id(self, default_shipping_account_id) + self.save() + except Exception as e: + self._log_error(e, preference) + + + def _save_invoice(self, invoice): + # Invoice can be Linked with Another Transactions + # If any of these transactions is a "StatementCharge" or "ReimburseCharge" then in the UI + # item list is populated from the corresponding transaction, these items are not shown in api response + # Also as of now there is no way of fetching the corresponding transaction from api + # We in order to correctly reflect account balance make an equivalent Journal Entry + quickbooks_id = "Invoice - {}".format(invoice["Id"]) + if any(linked["TxnType"] in ("StatementCharge", "ReimburseCharge") for linked in invoice["LinkedTxn"]): + self._save_invoice_as_journal_entry(invoice, quickbooks_id) + else: + self._save_sales_invoice(invoice, quickbooks_id) + + + def _save_credit_memo(self, credit_memo): + # Credit Memo is equivalent to a return Sales Invoice + quickbooks_id = "Credit Memo - {}".format(credit_memo["Id"]) + self._save_sales_invoice(credit_memo, quickbooks_id, is_return=True) + + + def _save_sales_receipt(self, sales_receipt): + # Sales Receipt is equivalent to a POS Sales Invoice + quickbooks_id = "Sales Receipt - {}".format(sales_receipt["Id"]) + self._save_sales_invoice(sales_receipt, quickbooks_id, is_pos=True) + + + def _save_refund_receipt(self, refund_receipt): + # Refund Receipt is equivalent to a return POS Sales Invoice + quickbooks_id = "Refund Receipt - {}".format(refund_receipt["Id"]) + self._save_sales_invoice(refund_receipt, quickbooks_id, is_return=True, is_pos=True) + + + def _save_sales_invoice(self, invoice, quickbooks_id, is_return=False, is_pos=False): + try: + if not frappe.db.exists({"doctype": "Sales Invoice", "quickbooks_id": quickbooks_id, "company": self.company}): + invoice_dict = { + "doctype": "Sales Invoice", + "quickbooks_id": quickbooks_id, + + # Quickbooks uses ISO 4217 Code + # of course this gonna come back to bite me + "currency": invoice["CurrencyRef"]["value"], + + # Exchange Rate is provided if multicurrency is enabled + # It is not provided if multicurrency is not enabled + "conversion_rate": invoice.get("ExchangeRate", 1), + "posting_date": invoice["TxnDate"], + + # QuickBooks doesn't make Due Date a mandatory field this is a hack + "due_date": invoice.get("DueDate", invoice["TxnDate"]), + "customer": frappe.get_all("Customer", + filters={ + "quickbooks_id": invoice["CustomerRef"]["value"], + "company": self.company, + })[0]["name"], + "items": self._get_si_items(invoice, is_return=is_return), + "taxes": self._get_taxes(invoice), + + # Do not change posting_date upon submission + "set_posting_time": 1, + + # QuickBooks doesn't round total + "disable_rounded_total": 1, + "is_return": is_return, + "is_pos": is_pos, + "payments": self._get_invoice_payments(invoice, is_return=is_return, is_pos=is_pos), + "company": self.company, + } + discount = self._get_discount(invoice["Line"]) + if discount: + if invoice["ApplyTaxAfterDiscount"]: + invoice_dict["apply_discount_on"] = "Net Total" + else: + invoice_dict["apply_discount_on"] = "Grand Total" + invoice_dict["discount_amount"] = discount["Amount"] + + invoice_doc = frappe.get_doc(invoice_dict) + invoice_doc.insert() + invoice_doc.submit() + except Exception as e: + self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) + + + def _get_si_items(self, invoice, is_return=False): + items = [] + for line in invoice["Line"]: + if line["DetailType"] == "SalesItemLineDetail": + if line["SalesItemLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["SalesItemLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in invoice["TxnTaxDetail"]: + tax_code = invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + if line["SalesItemLineDetail"]["ItemRef"]["value"] != "SHIPPING_ITEM_ID": + item = frappe.db.get_all("Item", + filters={ + "quickbooks_id": line["SalesItemLineDetail"]["ItemRef"]["value"], + "company": self.company, + }, + fields=["name", "stock_uom"] + )[0] + items.append({ + "item_code": item["name"], + "conversion_factor": 1, + "uom": item["stock_uom"], + "description": line.get("Description", line["SalesItemLineDetail"]["ItemRef"]["name"]), + "qty": line["SalesItemLineDetail"]["Qty"], + "price_list_rate": line["SalesItemLineDetail"]["UnitPrice"], + "cost_center": self.default_cost_center, + "warehouse": self.default_warehouse, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)) + }) + else: + items.append({ + "item_name": "Shipping", + "conversion_factor": 1, + "expense_account": self._get_account_name_by_id("TaxRate - {}".format(line["SalesItemLineDetail"]["TaxCodeRef"]["value"])), + "uom": "Unit", + "description": "Shipping", + "income_account": self.default_shipping_account, + "qty": 1, + "price_list_rate": line["Amount"], + "cost_center": self.default_cost_center, + "warehouse": self.default_warehouse, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)) + }) + if is_return: + items[-1]["qty"] *= -1 + elif line["DetailType"] == "DescriptionOnly": + items[-1].update({ + "margin_type": "Percentage", + "margin_rate_or_amount": int(line["Description"].split("%")[0]), + }) + return items + + + def _get_item_taxes(self, tax_code): + tax_rates = self.tax_rates + item_taxes = {} + if tax_code != "NON": + tax_code = self.tax_codes[tax_code] + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxTypeApplicable"] == "TaxOnAmount": + tax_head = self._get_account_name_by_id("TaxRate - {}".format(tax_rate_detail["TaxRateRef"]["value"])) + tax_rate = tax_rates[tax_rate_detail["TaxRateRef"]["value"]] + item_taxes[tax_head] = tax_rate["RateValue"] + return item_taxes + + + def _get_invoice_payments(self, invoice, is_return=False, is_pos=False): + if is_pos: + amount = invoice["TotalAmt"] + if is_return: + amount = -amount + return [{ + "mode_of_payment": "Cash", + "account": self._get_account_name_by_id(invoice["DepositToAccountRef"]["value"]), + "amount": amount, + }] + + + def _get_discount(self, lines): + for line in lines: + if line["DetailType"] == "DiscountLineDetail" and "Amount" in line["DiscountLineDetail"]: + return line + + + def _save_invoice_as_journal_entry(self, invoice, quickbooks_id): + try: + accounts = [] + for line in self.general_ledger["Invoice"][invoice["Id"]]["lines"]: + account_line = {"account": line["account"], "cost_center": self.default_cost_center} + if line["debit"]: + account_line["debit_in_account_currency"] = line["debit"] + elif line["credit"]: + account_line["credit_in_account_currency"] = line["credit"] + if frappe.db.get_value("Account", line["account"], "account_type") == "Receivable": + account_line["party_type"] = "Customer" + account_line["party"] = frappe.get_all("Customer", + filters={"quickbooks_id": invoice["CustomerRef"]["value"], "company": self.company} + )[0]["name"] + + accounts.append(account_line) + + posting_date = invoice["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [invoice, accounts]) + + + def _save_journal_entry(self, journal_entry): + # JournalEntry is equivalent to a Journal Entry + + def _get_je_accounts(lines): + # Converts JounalEntry lines to accounts list + posting_type_field_mapping = { + "Credit": "credit_in_account_currency", + "Debit": "debit_in_account_currency", + } + accounts = [] + for line in lines: + if line["DetailType"] == "JournalEntryLineDetail": + account_name = self._get_account_name_by_id(line["JournalEntryLineDetail"]["AccountRef"]["value"]) + posting_type = line["JournalEntryLineDetail"]["PostingType"] + accounts.append({ + "account": account_name, + posting_type_field_mapping[posting_type]: line["Amount"], + "cost_center": self.default_cost_center, + }) + return accounts + + quickbooks_id = "Journal Entry - {}".format(journal_entry["Id"]) + accounts = _get_je_accounts(journal_entry["Line"]) + posting_date = journal_entry["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + + + def __save_journal_entry(self, quickbooks_id, accounts, posting_date): + try: + if not frappe.db.exists({"doctype": "Journal Entry", "quickbooks_id": quickbooks_id, "company": self.company}): + je = frappe.get_doc({ + "doctype": "Journal Entry", + "quickbooks_id": quickbooks_id, + "company": self.company, + "posting_date": posting_date, + "accounts": accounts, + "multi_currency": 1, + }) + je.insert() + je.submit() + except Exception as e: + self._log_error(e, [accounts, json.loads(je.as_json())]) + + + def _save_bill(self, bill): + # Bill is equivalent to a Purchase Invoice + quickbooks_id = "Bill - {}".format(bill["Id"]) + self.__save_purchase_invoice(bill, quickbooks_id) + + + def _save_vendor_credit(self, vendor_credit): + # Vendor Credit is equivalent to a return Purchase Invoice + quickbooks_id = "Vendor Credit - {}".format(vendor_credit["Id"]) + self.__save_purchase_invoice(vendor_credit, quickbooks_id, is_return=True) + + + def __save_purchase_invoice(self, invoice, quickbooks_id, is_return=False): + try: + if not frappe.db.exists({"doctype": "Purchase Invoice", "quickbooks_id": quickbooks_id, "company": self.company}): + credit_to_account = self._get_account_name_by_id(invoice["APAccountRef"]["value"]) + invoice_dict = { + "doctype": "Purchase Invoice", + "quickbooks_id": quickbooks_id, + "currency": invoice["CurrencyRef"]["value"], + "conversion_rate": invoice.get("ExchangeRate", 1), + "posting_date": invoice["TxnDate"], + "due_date": invoice.get("DueDate", invoice["TxnDate"]), + "credit_to": credit_to_account, + "supplier": frappe.get_all("Supplier", + filters={ + "quickbooks_id": invoice["VendorRef"]["value"], + "company": self.company, + })[0]["name"], + "items": self._get_pi_items(invoice, is_return=is_return), + "taxes": self._get_taxes(invoice), + "set_posting_time": 1, + "disable_rounded_total": 1, + "is_return": is_return, + "udpate_stock": 0, + "company": self.company, + } + invoice_doc = frappe.get_doc(invoice_dict) + invoice_doc.insert() + invoice_doc.submit() + except Exception as e: + self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) + + + def _get_pi_items(self, purchase_invoice, is_return=False): + items = [] + for line in purchase_invoice["Line"]: + if line["DetailType"] == "ItemBasedExpenseLineDetail": + if line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: + tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + item = frappe.db.get_all("Item", + filters={ + "quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], + "company": self.company + }, + fields=["name", "stock_uom"] + )[0] + items.append({ + "item_code": item["name"], + "conversion_factor": 1, + "uom": item["stock_uom"], + "description": line.get("Description", line["ItemBasedExpenseLineDetail"]["ItemRef"]["name"]), + "qty": line["ItemBasedExpenseLineDetail"]["Qty"], + "price_list_rate": line["ItemBasedExpenseLineDetail"]["UnitPrice"], + "warehouse": self.default_warehouse, + "cost_center": self.default_cost_center, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), + }) + elif line["DetailType"] == "AccountBasedExpenseLineDetail": + if line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: + tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + items.append({ + "item_name": line.get("Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"]), + "conversion_factor": 1, + "expense_account": self._get_account_name_by_id(line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"]), + "uom": "Unit", + "description": line.get("Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"]), + "qty": 1, + "price_list_rate": line["Amount"], + "warehouse": self.default_warehouse, + "cost_center": self.default_cost_center, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), + }) + if is_return: + items[-1]["qty"] *= -1 + return items + + + def _save_payment(self, payment): + try: + quickbooks_id = "Payment - {}".format(payment["Id"]) + # If DepositToAccountRef is not set on payment that means it actually doesn't affect any accounts + # No need to record such payment + # Such payment record is created QuickBooks Payments API + if "DepositToAccountRef" not in payment: + return + + # A Payment can be linked to multiple transactions + accounts = [] + for line in payment["Line"]: + linked_transaction = line["LinkedTxn"][0] + if linked_transaction["TxnType"] == "Invoice": + si_quickbooks_id = "Invoice - {}".format(linked_transaction["TxnId"]) + # Invoice could have been saved as a Sales Invoice or a Journal Entry + if frappe.db.exists({"doctype": "Sales Invoice", "quickbooks_id": si_quickbooks_id, "company": self.company}): + sales_invoice = frappe.get_all("Sales Invoice", + filters={ + "quickbooks_id": si_quickbooks_id, + "company": self.company, + }, + fields=["name", "customer", "debit_to"], + )[0] + reference_type = "Sales Invoice" + reference_name = sales_invoice["name"] + party = sales_invoice["customer"] + party_account = sales_invoice["debit_to"] + + if frappe.db.exists({"doctype": "Journal Entry", "quickbooks_id": si_quickbooks_id, "company": self.company}): + journal_entry = frappe.get_doc("Journal Entry", + { + "quickbooks_id": si_quickbooks_id, + "company": self.company, + } + ) + # Invoice saved as a Journal Entry must have party and party_type set on line containing Receivable Account + customer_account_line = list(filter(lambda acc: acc.party_type == "Customer", journal_entry.accounts))[0] + + reference_type = "Journal Entry" + reference_name = journal_entry.name + party = customer_account_line.party + party_account = customer_account_line.account + + accounts.append({ + "party_type": "Customer", + "party": party, + "reference_type": reference_type, + "reference_name": reference_name, + "account": party_account, + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + deposit_account = self._get_account_name_by_id(payment["DepositToAccountRef"]["value"]) + accounts.append({ + "account": deposit_account, + "debit_in_account_currency": payment["TotalAmt"], + "cost_center": self.default_cost_center, + }) + posting_date = payment["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [payment, accounts]) + + + def _save_bill_payment(self, bill_payment): + try: + quickbooks_id = "BillPayment - {}".format(bill_payment["Id"]) + # A BillPayment can be linked to multiple transactions + accounts = [] + for line in bill_payment["Line"]: + linked_transaction = line["LinkedTxn"][0] + if linked_transaction["TxnType"] == "Bill": + pi_quickbooks_id = "Bill - {}".format(linked_transaction["TxnId"]) + if frappe.db.exists({"doctype": "Purchase Invoice", "quickbooks_id": pi_quickbooks_id, "company": self.company}): + purchase_invoice = frappe.get_all("Purchase Invoice", + filters={ + "quickbooks_id": pi_quickbooks_id, + "company": self.company, + }, + fields=["name", "supplier", "credit_to"], + )[0] + reference_type = "Purchase Invoice" + reference_name = purchase_invoice["name"] + party = purchase_invoice["supplier"] + party_account = purchase_invoice["credit_to"] + accounts.append({ + "party_type": "Supplier", + "party": party, + "reference_type": reference_type, + "reference_name": reference_name, + "account": party_account, + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + if bill_payment["PayType"] == "Check": + bank_account_id = bill_payment["CheckPayment"]["BankAccountRef"]["value"] + elif bill_payment["PayType"] == "CreditCard": + bank_account_id = bill_payment["CreditCardPayment"]["CCAccountRef"]["value"] + + bank_account = self._get_account_name_by_id(bank_account_id) + accounts.append({ + "account": bank_account, + "credit_in_account_currency": bill_payment["TotalAmt"], + "cost_center": self.default_cost_center, + }) + posting_date = bill_payment["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [bill_payment, accounts]) + + + def _save_purchase(self, purchase): + try: + quickbooks_id = "Purchase - {}".format(purchase["Id"]) + # Credit Bank Account + accounts = [{ + "account": self._get_account_name_by_id(purchase["AccountRef"]["value"]), + "credit_in_account_currency": purchase["TotalAmt"], + "cost_center": self.default_cost_center, + }] + + # Debit Mentioned Accounts + for line in purchase["Line"]: + if line["DetailType"] == "AccountBasedExpenseLineDetail": + account = self._get_account_name_by_id(line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"]) + elif line["DetailType"] == "ItemBasedExpenseLineDetail": + account = frappe.get_doc("Item", + {"quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], "company": self.company} + ).item_defaults[0].expense_account + accounts.append({ + "account": account, + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # Debit Tax Accounts + if "TxnTaxDetail" in purchase: + for line in purchase["TxnTaxDetail"]["TaxLine"]: + accounts.append({ + "account": self._get_account_name_by_id("TaxRate - {}".format(line["TaxLineDetail"]["TaxRateRef"]["value"])), + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # If purchase["Credit"] is set to be True then it represents a refund + if purchase.get("Credit"): + for account in accounts: + if "debit_in_account_currency" in account: + account["credit_in_account_currency"] = account["debit_in_account_currency"] + del account["debit_in_account_currency"] + else: + account["debit_in_account_currency"] = account["credit_in_account_currency"] + del account["credit_in_account_currency"] + + posting_date = purchase["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [purchase, accounts]) + + + def _save_deposit(self, deposit): + try: + quickbooks_id = "Deposit - {}".format(deposit["Id"]) + # Debit Bank Account + accounts = [{ + "account": self._get_account_name_by_id(deposit["DepositToAccountRef"]["value"]), + "debit_in_account_currency": deposit["TotalAmt"], + "cost_center": self.default_cost_center, + }] + + # Credit Mentioned Accounts + for line in deposit["Line"]: + if "LinkedTxn" in line: + accounts.append({ + "account": self.undeposited_funds_account, + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + else: + accounts.append({ + "account": self._get_account_name_by_id(line["DepositLineDetail"]["AccountRef"]["value"]), + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # Debit Cashback if mentioned + if "CashBack" in deposit: + accounts.append({ + "account": self._get_account_name_by_id(deposit["CashBack"]["AccountRef"]["value"]), + "debit_in_account_currency": deposit["CashBack"]["Amount"], + "cost_center": self.default_cost_center, + }) + + posting_date = deposit["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [deposit, accounts]) + + + def _save_advance_payment(self, advance_payment): + quickbooks_id = "Advance Payment - {}".format(advance_payment["id"]) + self.__save_ledger_entry_as_je(advance_payment, quickbooks_id) + + + def _save_tax_payment(self, tax_payment): + quickbooks_id = "Tax Payment - {}".format(tax_payment["id"]) + self.__save_ledger_entry_as_je(tax_payment, quickbooks_id) + + + def _save_inventory_qty_adjust(self, inventory_qty_adjust): + quickbooks_id = "Inventory Qty Adjust - {}".format(inventory_qty_adjust["id"]) + self.__save_ledger_entry_as_je(inventory_qty_adjust, quickbooks_id) + + + def __save_ledger_entry_as_je(self, ledger_entry, quickbooks_id): + try: + accounts = [] + for line in ledger_entry["lines"]: + account_line = {"account": line["account"], "cost_center": self.default_cost_center} + if line["credit"]: + account_line["credit_in_account_currency"] = line["credit"] + else: + account_line["debit_in_account_currency"] = line["debit"] + accounts.append(account_line) + + posting_date = ledger_entry["date"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, ledger_entry) + + + def _get_taxes(self, entry): + taxes = [] + if "TxnTaxDetail" not in entry or "TaxLine" not in entry["TxnTaxDetail"]: + return taxes + for line in entry["TxnTaxDetail"]["TaxLine"]: + tax_rate = line["TaxLineDetail"]["TaxRateRef"]["value"] + account_head = self._get_account_name_by_id("TaxRate - {}".format(tax_rate)) + tax_type_applicable = self._get_tax_type(tax_rate) + if tax_type_applicable == "TaxOnAmount": + taxes.append({ + "charge_type": "On Net Total", + "account_head": account_head, + "description": account_head, + "cost_center": self.default_cost_center, + "rate": 0, + }) + else: + parent_tax_rate = self._get_parent_tax_rate(tax_rate) + parent_row_id = self._get_parent_row_id(parent_tax_rate, taxes) + taxes.append({ + "charge_type": "On Previous Row Amount", + "row_id": parent_row_id, + "account_head": account_head, + "description": account_head, + "cost_center": self.default_cost_center, + "rate": line["TaxLineDetail"]["TaxPercent"], + }) + return taxes + + + def _get_tax_type(self, tax_rate): + for tax_code in self.tax_codes.values(): + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: + return tax_rate_detail["TaxTypeApplicable"] + + + def _get_parent_tax_rate(self, tax_rate): + parent = None + for tax_code in self.tax_codes.values(): + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: + parent = tax_rate_detail["TaxOnTaxOrder"] + if parent: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxOrder"] == parent: + return tax_rate_detail["TaxRateRef"]["value"] + + + def _get_parent_row_id(self, tax_rate, taxes): + tax_account = self._get_account_name_by_id("TaxRate - {}".format(tax_rate)) + for index, tax in enumerate(taxes): + if tax["account_head"] == tax_account: + return index + 1 + + + def _create_address(self, entity, doctype, address, address_type): + try : + if not frappe.db.exists({"doctype": "Address", "quickbooks_id": address["Id"]}): + frappe.get_doc({ + "doctype": "Address", + "quickbooks_address_id": address["Id"], + "address_title": entity.name, + "address_type": address_type, + "address_line1": address["Line1"], + "city": address["City"], + "links": [{"link_doctype": doctype, "link_name": entity.name}] + }).insert() + except Exception as e: + self._log_error(e, address) + + + def _get(self, *args, **kwargs): + kwargs["headers"] = { + "Accept": "application/json", + "Authorization": "Bearer {}".format(self.access_token) + } + response = requests.get(*args, **kwargs) + # HTTP Status code 401 here means that the access_token is expired + # We can refresh tokens and retry + # However limitless recursion does look dangerous + if response.status_code == 401: + self._refresh_tokens() + response = self._get(*args, **kwargs) + return response + + + def _get_account_name_by_id(self, quickbooks_id): + return frappe.get_all("Account", filters={"quickbooks_id": quickbooks_id, "company": self.company})[0]["name"] + + + def _publish(self, *args, **kwargs): + frappe.publish_realtime("quickbooks_progress_update", *args, **kwargs) + + + def _get_unique_account_name(self, quickbooks_name, number=0): + if number: + quickbooks_account_name = "{} - {} - QB".format(quickbooks_name, number) + else: + quickbooks_account_name = "{} - QB".format(quickbooks_name) + company_encoded_account_name = encode_company_abbr(quickbooks_account_name, self.company) + if frappe.db.exists({"doctype": "Account", "name": company_encoded_account_name, "company": self.company}): + unique_account_name = self._get_unique_account_name(quickbooks_name, number + 1) + else: + unique_account_name = quickbooks_account_name + return unique_account_name + + + def _log_error(self, execption, data=""): + import json, traceback + traceback.print_exc() + frappe.log_error(title="QuickBooks Migration Error", + message="\n".join([ + "Data", + json.dumps(data, + sort_keys=True, + indent=4, + separators=(',', ': ') + ), + "Exception", + traceback.format_exc() + ]) + ) + + + def set_indicator(self, status): + self.status = status + self.save() + frappe.db.commit() diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js new file mode 100644 index 0000000000..b71d704807 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: QuickBooks Migrator", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new QuickBooks Migrator + () => frappe.tests.make('QuickBooks Migrator', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py new file mode 100644 index 0000000000..6ce7c92ae8 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest + +class TestQuickBooksMigrator(unittest.TestCase): + pass From 7a7615ed66801b4fcd9723d3051e8d3379019de8 Mon Sep 17 00:00:00 2001 From: Charles-Henri Decultot Date: Thu, 18 Oct 2018 08:58:06 +0200 Subject: [PATCH 04/12] Safe encode payer name (#15726) --- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index f3e85b231d..014efd948c 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -123,7 +123,7 @@ class PaymentRequest(Document): "reference_doctype": "Payment Request", "reference_docname": self.name, "payer_email": self.email_to or frappe.session.user, - "payer_name": frappe.safe_decode(data.customer_name), + "payer_name": frappe.safe_encode(data.customer_name), "order_id": self.name, "currency": self.currency }) From 500ddc94c76d1289294b1890e65e5ea4134c07f2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 18 Oct 2018 16:48:28 +0530 Subject: [PATCH 05/12] fix(gle): GL Entry for invoices before introduction of rounding_adjustment (#15732) --- .../accounts/doctype/purchase_invoice/purchase_invoice.py | 5 ++++- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 5d11797a5c..dcfd8bb7ea 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -363,7 +363,10 @@ class PurchaseInvoice(BuyingController): return gl_entries def make_supplier_gl_entry(self, gl_entries): - grand_total = self.rounded_total or self.grand_total + # Checked both rounding_adjustment and rounded_total + # because rounded_total had value even before introcution of posting GLE based on rounded total + grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total + if grand_total: # Didnot use base_grand_total to book rounding loss gle grand_total_in_company_currency = flt(grand_total * self.conversion_rate, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 27d35eb74f..a6cd352b14 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -641,7 +641,9 @@ class SalesInvoice(SellingController): return gl_entries def make_customer_gl_entry(self, gl_entries): - grand_total = self.rounded_total or self.grand_total + # Checked both rounding_adjustment and rounded_total + # because rounded_total had value even before introcution of posting GLE based on rounded total + grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total if grand_total: # Didnot use base_grand_total to book rounding loss gle grand_total_in_company_currency = flt(grand_total * self.conversion_rate, From 25e9db5e813642f165273810124b2eccc83af86e Mon Sep 17 00:00:00 2001 From: Raffael Meyer Date: Thu, 18 Oct 2018 13:19:49 +0200 Subject: [PATCH 06/12] Do not display gender for companies (#15728) --- erpnext/crm/doctype/lead/lead.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 4070091130..b0be2a9f48 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -287,6 +287,7 @@ "bold": 0, "collapsible": 0, "columns": 0, + "depends_on": "eval:!doc.organization_lead", "fieldname": "gender", "fieldtype": "Link", "hidden": 0, @@ -1521,4 +1522,4 @@ "track_changes": 0, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} From 25edac451d5e837dd87cee5d711fe320ad42add5 Mon Sep 17 00:00:00 2001 From: deepeshgarg007 <42651287+deepeshgarg007@users.noreply.github.com> Date: Thu, 18 Oct 2018 17:43:41 +0530 Subject: [PATCH 07/12] Production Analytics Query Report (#15673) * Production Analytics Query Report * Changes in production analytics report * Updated json file and other changes --- .../report/production_analytics/__init__.py | 0 .../production_analytics.js | 46 +++++ .../production_analytics.json | 27 +++ .../production_analytics.py | 162 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 erpnext/manufacturing/report/production_analytics/__init__.py create mode 100644 erpnext/manufacturing/report/production_analytics/production_analytics.js create mode 100644 erpnext/manufacturing/report/production_analytics/production_analytics.json create mode 100644 erpnext/manufacturing/report/production_analytics/production_analytics.py diff --git a/erpnext/manufacturing/report/production_analytics/__init__.py b/erpnext/manufacturing/report/production_analytics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.js b/erpnext/manufacturing/report/production_analytics/production_analytics.js new file mode 100644 index 0000000000..b7b8f05d89 --- /dev/null +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.js @@ -0,0 +1,46 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Production Analytics"] = { + "filters": [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.defaults.get_user_default("year_start_date"), + reqd: 1 + }, + { + fieldname:"to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.defaults.get_user_default("year_end_date"), + reqd: 1 + }, + { + fieldname: "range", + label: __("Range"), + fieldtype: "Select", + options: [ + { "value": "Weekly", "label": __("Weekly") }, + { "value": "Monthly", "label": __("Monthly") }, + { "value": "Quarterly", "label": __("Quarterly") }, + { "value": "Yearly", "label": __("Yearly") } + ], + default: "Monthly", + reqd: 1 + } + ], + "formatter": function(value, row, column, data) { + return value; + } +} diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.json b/erpnext/manufacturing/report/production_analytics/production_analytics.json new file mode 100644 index 0000000000..023e0a8929 --- /dev/null +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "creation": "2018-10-11 19:28:37.085066", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 0, + "is_standard": "Yes", + "letter_head": "", + "modified": "2018-10-11 19:28:37.085066", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Analytics", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Work Order", + "report_name": "Production Analytics", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing User" + }, + { + "role": "Stock User" + } + ] +} \ No newline at end of file diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.py b/erpnext/manufacturing/report/production_analytics/production_analytics.py new file mode 100644 index 0000000000..1dc821c6e1 --- /dev/null +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.py @@ -0,0 +1,162 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import getdate +from erpnext.selling.report.sales_analytics.sales_analytics import (get_period_date_ranges,get_period) + +def execute(filters=None): + columns = get_columns(filters) + data, chart = get_data(filters,columns) + return columns, data,None ,chart + +def get_columns(filters): + + columns =[ + { + "label": _("Status"), + "fieldname": "Status", + "fieldtype": "Data", + "width": 140 + }] + + ranges = get_period_date_ranges(period=filters["range"], year_start_date = filters["from_date"],year_end_date=filters["to_date"]) + + for dummy, end_date in ranges: + + label = field_name = get_period(end_date,filters["range"]) + + columns.append( + { + "label": _(label), + "field_name":field_name, + "fieldtype": "Float", + "width": 120 + }, + ) + + return columns + +def get_data_list(filters,entry): + + data_list = { + "All Work Orders" : {}, + "Not Started" : {}, + "Overdue" : {}, + "Pending" : {}, + "Completed" : {} + } + + ranges = get_period_date_ranges(period=filters["range"], year_start_date = filters["from_date"],year_end_date=filters["to_date"]) + + for from_date,end_date in ranges: + period = get_period(end_date,filters["range"]) + for d in entry: + if getdate(d.creation) <= getdate(from_date) or getdate(d.creation) <= getdate(end_date) : + data_list = update_data_list(data_list,"All Work Orders",period) + + if d.status == 'Completed': + if getdate(d.actual_end_date) < getdate(from_date) or getdate(d.modified) < getdate(from_date): + data_list = update_data_list(data_list, "Completed",period) + + elif getdate(d.actual_start_date) < getdate(from_date) : + data_list = update_data_list(data_list, "Pending", period) + + elif getdate(d.planned_start_date) < getdate(from_date) : + data_list = update_data_list(data_list, "Overdue", period) + + else: + data_list = update_data_list(data_list, "Not Started", period) + + elif d.status == 'In Process': + if getdate(d.actual_start_date) < getdate(from_date) : + data_list = update_data_list(data_list, "Pending", period) + + elif getdate(d.planned_start_date) < getdate(from_date) : + data_list = update_data_list(data_list, "Overdue", period) + + else: + data_list = update_data_list(data_list, "Not Started", period) + + elif d.status == 'Not Started': + if getdate(d.planned_start_date) < getdate(from_date) : + data_list = update_data_list(data_list, "Overdue", period) + + else: + data_list = update_data_list(data_list, "Not Started", period) + return data_list + +def update_data_list(data_list, status, period): + if data_list.get(status).get(period): + data_list[status][period] += 1 + else: + data_list[status][period] = 1 + + return data_list + +def get_data(filters,columns): + + data = [] + + entry = frappe.get_all("Work Order", + fields=["creation", "modified", "actual_start_date", "actual_end_date", "planned_start_date", "planned_end_date", "status"], + filters={"docstatus" : 1, "company" : filters["company"] }) + + data_list = get_data_list(filters,entry) + + labels = ["All Work Orders", "Not Started", "Overdue", "Pending", "Completed"] + + chart_data = get_chart_data(data_list,columns) + + ranges = get_period_date_ranges(period=filters["range"], year_start_date = filters["from_date"],year_end_date=filters["to_date"]) + + for label in labels: + work = {} + work["Status"] = label + for dummy,end_date in ranges: + period = get_period(end_date,filters["range"]) + if data_list.get(label).get(period): + work[period] = data_list.get(label).get(period) + else: + work[period] = 0.0 + data.append(work) + + return data, chart_data + +def get_chart_data(data_list,columns): + + labels = [d.get("label") for d in columns[1:]] + + all_data, not_start, overdue, pending, completed = [], [], [] , [], [] + datasets = [] + + for d in labels: + all_data.append(data_list.get("All Work Orders").get(d)) + not_start.append(data_list.get("Not Started").get(d)) + overdue.append(data_list.get("Overdue").get(d)) + pending.append(data_list.get("Pending").get(d)) + completed.append(data_list.get("Completed").get(d)) + + datasets.append({'name':'All Work Orders', 'values': all_data}) + datasets.append({'name':'Not Started', 'values': not_start}) + datasets.append({'name':'Overdue', 'values': overdue}) + datasets.append({'name':'Pending', 'values': pending}) + datasets.append({'name':'Completed', 'values': completed}) + + chart = { + "data": { + 'labels': labels, + 'datasets':datasets + } + } + + chart["type"] = "line" + + return chart + + + + + From 3c9155e406a5132abaf2d1e25c2d4e0e0fcd4ece Mon Sep 17 00:00:00 2001 From: Saif Date: Thu, 18 Oct 2018 17:27:56 +0500 Subject: [PATCH 08/12] [Minor Fix] Force toggle display Net Total field when default taxes are fetched (#15532) * Minor fix for the problem that Net Total, Net Rate, Net Amount field would not be shown when default taxes were fetched * Prevent net_rate from becoming NaN * -Fixed problem that party_account_currency was not set in mapped document -Fixed problem that discount amount was not recalculated (in mapped document) -Fixed problem that set_dynamic_fields would work only on certain condition that led to showing Total (Company Currency) field even when using company currency --- .../doctype/purchase_invoice/purchase_invoice.py | 1 + .../doctype/sales_invoice/sales_invoice.py | 1 + erpnext/controllers/taxes_and_totals.py | 1 + erpnext/public/js/controllers/taxes_and_totals.js | 4 ++-- erpnext/public/js/controllers/transaction.js | 15 +++++---------- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 7219d875ad..8e55514194 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -123,6 +123,7 @@ class PurchaseInvoice(BuyingController): def set_missing_values(self, for_validate=False): if not self.credit_to: self.credit_to = get_party_account("Supplier", self.supplier, self.company) + self.party_account_currency = frappe.db.get_value("Account", self.credit_to, "account_currency", cache=True) if not self.due_date: self.due_date = get_due_date(self.posting_date, "Supplier", self.supplier, self.company, self.bill_date) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index d730e8859f..7f755dd92b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -310,6 +310,7 @@ class SalesInvoice(SellingController): if not self.debit_to: self.debit_to = get_party_account("Customer", self.customer, self.company) + self.party_account_currency = frappe.db.get_value("Account", self.debit_to, "account_currency", cache=True) if not self.due_date and self.customer: self.due_date = get_due_date(self.posting_date, "Customer", self.customer, self.company) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index bf1c13954c..6985c80bc1 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -70,6 +70,7 @@ class calculate_taxes_and_totals(object): if item.rate_with_margin > 0 else item.rate item.net_rate = item.rate + item.discount_amount = item.price_list_rate - item.rate item.amount = flt(item.rate * item.qty, item.precision("amount")) item.net_amount = item.amount diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index ecf1d955ba..21e02b7c80 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -161,7 +161,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ if(cumulated_tax_fraction && !me.discount_amount_applied) { item.net_amount = flt(item.amount / (1 + cumulated_tax_fraction)); - item.net_rate = flt(item.net_amount / item.qty, precision("net_rate", item)); + item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; me.set_in_company_currency(item, ["net_rate", "net_amount"]); } @@ -521,7 +521,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ item.net_amount = flt(item.net_amount + discount_amount_loss, precision("net_amount", item)); } - item.net_rate = flt(item.net_amount / item.qty, precision("net_rate", item)); + item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; me.set_in_company_currency(item, ["net_rate", "net_amount"]); }); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index a27161f012..3e27d5638d 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -286,6 +286,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ me.frm.set_value("taxes", r.message.taxes); } }, + () => me.set_dynamic_labels(), () => me.calculate_taxes_and_totals() ]); } @@ -845,16 +846,10 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ this.frm.toggle_reqd("plc_conversion_rate", !!(this.frm.doc.price_list_name && this.frm.doc.price_list_currency)); - if(this.frm.doc_currency!==this.frm.doc.currency - || this.frm.doc_currency!==this.frm.doc.price_list_currency) { - // reset names only when the currency is different - - var company_currency = this.get_company_currency(); - this.change_form_labels(company_currency); - this.change_grid_labels(company_currency); - this.frm.refresh_fields(); - this.frm.doc_currency = this.frm.doc.currency; - } + var company_currency = this.get_company_currency(); + this.change_form_labels(company_currency); + this.change_grid_labels(company_currency); + this.frm.refresh_fields(); }, change_form_labels: function(company_currency) { From b4cf72c770315bbb8f1161f87f28596901194ddc Mon Sep 17 00:00:00 2001 From: Saif Date: Thu, 18 Oct 2018 17:29:47 +0500 Subject: [PATCH 09/12] Adding "Include UOM" in Reports with Qty and Rates (#15541) * Added Include UOM field for Stock Balane, Stock Ledger and Stock Projected Qty * Add columns in result list-of-lists instead of converting reports to list-of-dicts * For requested changes -Merged conversion factor query with item detail queries -Ensuring snail_case -Made columns consistently list-of-dicts --- .../report/stock_balance/stock_balance.js | 6 ++ .../report/stock_balance/stock_balance.py | 60 ++++++++++------ .../stock/report/stock_ledger/stock_ledger.js | 6 ++ .../stock/report/stock_ledger/stock_ledger.py | 58 +++++++++------ .../stock_projected_qty.js | 6 ++ .../stock_projected_qty.py | 72 +++++++++++++------ erpnext/stock/utils.py | 31 ++++++++ 7 files changed, 174 insertions(+), 65 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index a563564853..839ed7ab5b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -51,6 +51,12 @@ frappe.query_reports["Stock Balance"] = { "width": "80", "options": "Warehouse" }, + { + "fieldname":"include_uom", + "label": __("Include UOM"), + "fieldtype": "Link", + "options": "UOM" + }, { "fieldname": "show_variant_attributes", "label": __("Show Variant Attributes"), diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index e6ca5c2d7a..e72e94b12d 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.utils import flt, cint, getdate, now +from erpnext.stock.utils import update_included_uom_in_report from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition from six import iteritems @@ -14,6 +15,7 @@ def execute(filters=None): validate_filters(filters) + include_uom = filters.get("include_uom") columns = get_columns() items = get_items(filters) sle = get_stock_ledger_entries(filters, items) @@ -27,6 +29,7 @@ def execute(filters=None): item_reorder_detail_map = get_item_reorder_details(item_map.keys()) data = [] + conversion_factors = [] for (company, item, warehouse) in sorted(iwb_map): if item_map.get(item): qty_dict = iwb_map[(company, item, warehouse)] @@ -54,36 +57,40 @@ def execute(filters=None): variants_attributes = get_variants_attributes() report_data += [item_map[item].get(i) for i in variants_attributes] + if include_uom: + conversion_factors.append(item_map[item].conversion_factor) + data.append(report_data) if filters.get('show_variant_attributes', 0) == 1: columns += ["{}:Data:100".format(i) for i in get_variants_attributes()] + update_included_uom_in_report(columns, data, include_uom, conversion_factors) return columns, data def get_columns(): """return columns""" columns = [ - _("Item")+":Link/Item:100", - _("Item Name")+"::150", - _("Item Group")+":Link/Item Group:100", - _("Brand")+":Link/Brand:90", - _("Description")+"::140", - _("Warehouse")+":Link/Warehouse:100", - _("Stock UOM")+":Link/UOM:90", - _("Opening Qty")+":Float:100", - _("Opening Value")+":Float:110", - _("In Qty")+":Float:80", - _("In Value")+":Float:80", - _("Out Qty")+":Float:80", - _("Out Value")+":Float:80", - _("Balance Qty")+":Float:100", - _("Balance Value")+":Float:100", - _("Valuation Rate")+":Float:90", - _("Reorder Level")+":Float:80", - _("Reorder Qty")+":Float:80", - _("Company")+":Link/Company:100" + {"label": _("Item"), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100}, + {"label": _("Item Name"), "fieldname": "item_name", "width": 150}, + {"label": _("Item Group"), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100}, + {"label": _("Brand"), "fieldname": "brand", "fieldtype": "Link", "options": "Brand", "width": 90}, + {"label": _("Description"), "fieldname": "description", "width": 140}, + {"label": _("Warehouse"), "fieldname": "warehouse", "fieldtype": "Link", "options": "Warehouse", "width": 100}, + {"label": _("Stock UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 90}, + {"label": _("Opening Qty"), "fieldname": "opening_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Opening Value"), "fieldname": "opening_val", "fieldtype": "Float", "width": 110}, + {"label": _("In Qty"), "fieldname": "in_qty", "fieldtype": "Float", "width": 80, "convertible": "qty"}, + {"label": _("In Value"), "fieldname": "in_val", "fieldtype": "Float", "width": 80}, + {"label": _("Out Qty"), "fieldname": "out_qty", "fieldtype": "Float", "width": 80, "convertible": "qty"}, + {"label": _("Out Value"), "fieldname": "out_val", "fieldtype": "Float", "width": 80}, + {"label": _("Balance Qty"), "fieldname": "bal_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Balance Value"), "fieldname": "bal_val", "fieldtype": "Currency", "width": 100}, + {"label": _("Valuation Rate"), "fieldname": "val_rate", "fieldtype": "Currency", "width": 90, "convertible": "rate"}, + {"label": _("Reorder Level"), "fieldname": "reorder_level", "fieldtype": "Float", "width": 80, "convertible": "qty"}, + {"label": _("Reorder Qty"), "fieldname": "reorder_qty", "fieldtype": "Float", "width": 80, "convertible": "qty"}, + {"label": _("Company"), "fieldname": "company", "fieldtype": "Link", "options": "Company", "width": 100} ] return columns @@ -210,11 +217,18 @@ def get_item_details(items, sle, filters): items = list(set([d.item_code for d in sle])) if items: + cf_field = cf_join = "" + if filters.get("include_uom"): + cf_field = ", ucd.conversion_factor" + cf_join = "left join `tabUOM Conversion Detail` ucd on ucd.parent=item.name and ucd.uom=%(include_uom)s" + for item in frappe.db.sql(""" - select name, item_name, description, item_group, brand, stock_uom - from `tabItem` - where name in ({0}) and ifnull(disabled, 0) = 0 - """.format(', '.join(['"' + frappe.db.escape(i, percent=False) + '"' for i in items])), as_dict=1): + select item.name, item.item_name, item.description, item.item_group, item.brand, item.stock_uom{cf_field} + from `tabItem` item + {cf_join} + where item.name in ({names}) and ifnull(item.disabled, 0) = 0 + """.format(cf_field=cf_field, cf_join=cf_join, names=', '.join(['"' + frappe.db.escape(i, percent=False) + '"' for i in items])), + {"include_uom": filters.get("include_uom")}, as_dict=1): item_details.setdefault(item.name, item) if filters.get('show_variant_attributes', 0) == 1: diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index 660357cdc3..3fab3273b9 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -70,6 +70,12 @@ frappe.query_reports["Stock Ledger"] = { "label": __("Project"), "fieldtype": "Link", "options": "Project" + }, + { + "fieldname":"include_uom", + "label": __("Include UOM"), + "fieldtype": "Link", + "options": "UOM" } ] } diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 9237cfd2aa..578000bfa1 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -4,15 +4,18 @@ from __future__ import unicode_literals import frappe from frappe import _ +from erpnext.stock.utils import update_included_uom_in_report def execute(filters=None): + include_uom = filters.get("include_uom") columns = get_columns() items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) - item_details = get_item_details(items, sl_entries) + item_details = get_item_details(items, sl_entries, include_uom) opening_row = get_opening_balance(filters, columns) data = [] + conversion_factors = [] if opening_row: data.append(opening_row) @@ -26,28 +29,36 @@ def execute(filters=None): sle.valuation_rate, sle.stock_value, sle.voucher_type, sle.voucher_no, sle.batch_no, sle.serial_no, sle.project, sle.company]) + if include_uom: + conversion_factors.append(item_detail.conversion_factor) + + update_included_uom_in_report(columns, data, include_uom, conversion_factors) return columns, data def get_columns(): columns = [ - _("Date") + ":Datetime:95", _("Item") + ":Link/Item:130", - _("Item Name") + "::100", _("Item Group") + ":Link/Item Group:100", - _("Brand") + ":Link/Brand:100", _("Description") + "::200", - _("Warehouse") + ":Link/Warehouse:100", _("Stock UOM") + ":Link/UOM:100", - _("Qty") + ":Float:50", _("Balance Qty") + ":Float:100", + {"label": _("Date"), "fieldname": "date", "fieldtype": "Datetime", "width": 95}, + {"label": _("Item"), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 130}, + {"label": _("Item Name"), "fieldname": "item_name", "width": 100}, + {"label": _("Item Group"), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100}, + {"label": _("Brand"), "fieldname": "brand", "fieldtype": "Link", "options": "Brand", "width": 100}, + {"label": _("Description"), "fieldname": "description", "width": 200}, + {"label": _("Warehouse"), "fieldname": "warehouse", "fieldtype": "Link", "options": "Warehouse", "width": 100}, + {"label": _("Stock UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 100}, + {"label": _("Qty"), "fieldname": "actual_qty", "fieldtype": "Float", "width": 50, "convertible": "qty"}, + {"label": _("Balance Qty"), "fieldname": "qty_after_transaction", "fieldtype": "Float", "width": 100, "convertible": "qty"}, {"label": _("Incoming Rate"), "fieldname": "incoming_rate", "fieldtype": "Currency", "width": 110, - "options": "Company:company:default_currency"}, + "options": "Company:company:default_currency", "convertible": "rate"}, {"label": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, - "options": "Company:company:default_currency"}, + "options": "Company:company:default_currency", "convertible": "rate"}, {"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"}, - _("Voucher Type") + "::110", - _("Voucher #") + ":Dynamic Link/" + _("Voucher Type") + ":100", - _("Batch") + ":Link/Batch:100", - _("Serial #") + ":Link/Serial No:100", - _("Project") + ":Link/Project:100", - {"label": _("Company"), "fieldtype": "Link", "width": 110, - "options": "company", "fieldname": "company"} + {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, + {"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100}, + {"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100}, + {"label": _("Serial #"), "fieldname": "serial_no", "fieldtype": "Link", "options": "Serial No", "width": 100}, + {"label": _("Project"), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100}, + {"label": _("Company"), "fieldname": "company", "fieldtype": "Link", "options": "Company", "width": 110} ] return columns @@ -88,7 +99,7 @@ def get_items(filters): .format(" and ".join(conditions)), filters) return items -def get_item_details(items, sl_entries): +def get_item_details(items, sl_entries, include_uom): item_details = {} if not items: items = list(set([d.item_code for d in sl_entries])) @@ -96,11 +107,18 @@ def get_item_details(items, sl_entries): if not items: return item_details + cf_field = cf_join = "" + if include_uom: + cf_field = ", ucd.conversion_factor" + cf_join = "left join `tabUOM Conversion Detail` ucd on ucd.parent=item.name and ucd.uom=%(include_uom)s" + for item in frappe.db.sql(""" - select name, item_name, description, item_group, brand, stock_uom - from `tabItem` - where name in ({0}) - """.format(', '.join(['"' + frappe.db.escape(i,percent=False) + '"' for i in items])), as_dict=1): + select item.name, item.item_name, item.description, item.item_group, item.brand, item.stock_uom{cf_field} + from `tabItem` item + {cf_join} + where item.name in ({names}) + """.format(cf_field=cf_field, cf_join=cf_join, names=', '.join(['"' + frappe.db.escape(i, percent=False) + '"' for i in items])), + {"include_uom": include_uom}, as_dict=1): item_details.setdefault(item.name, item) return item_details diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.js b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.js index 51b9b0cf23..6589688d1a 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.js +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.js @@ -37,6 +37,12 @@ frappe.query_reports["Stock Projected Qty"] = { "label": __("Brand"), "fieldtype": "Link", "options": "Brand" + }, + { + "fieldname":"include_uom", + "label": __("Include UOM"), + "fieldtype": "Link", + "options": "UOM" } ] } diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py index 3e6e5a5cd2..d6be6c08cf 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py @@ -5,27 +5,18 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.utils import flt, today +from erpnext.stock.utils import update_included_uom_in_report def execute(filters=None): filters = frappe._dict(filters or {}) - return get_columns(), get_data(filters) - -def get_columns(): - return [_("Item Code") + ":Link/Item:140", _("Item Name") + "::100", _("Description") + "::200", - _("Item Group") + ":Link/Item Group:100", _("Brand") + ":Link/Brand:100", _("Warehouse") + ":Link/Warehouse:120", - _("UOM") + ":Link/UOM:100", _("Actual Qty") + ":Float:100", _("Planned Qty") + ":Float:100", - _("Requested Qty") + ":Float:110", _("Ordered Qty") + ":Float:100", - _("Reserved Qty") + ":Float:100", _("Reserved Qty for Production") + ":Float:100", - _("Reserved for sub contracting") + ":Float:100", - _("Projected Qty") + ":Float:100", _("Reorder Level") + ":Float:100", _("Reorder Qty") + ":Float:100", - _("Shortage Qty") + ":Float:100"] - -def get_data(filters): + include_uom = filters.get("include_uom") + columns = get_columns() bin_list = get_bin_list(filters) - item_map = get_item_map(filters.get("item_code")) + item_map = get_item_map(filters.get("item_code"), include_uom) + warehouse_company = {} data = [] - + conversion_factors = [] for bin in bin_list: item = item_map.get(bin.item_code) @@ -60,7 +51,35 @@ def get_data(filters): bin.reserved_qty, bin.reserved_qty_for_production, bin.reserved_qty_for_sub_contract, bin.projected_qty, re_order_level, re_order_qty, shortage_qty]) - return data + if include_uom: + conversion_factors.append(item.conversion_factor) + + update_included_uom_in_report(columns, data, include_uom, conversion_factors) + return columns, data + +def get_columns(): + return [ + {"label": _("Item Code"), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 140}, + {"label": _("Item Name"), "fieldname": "item_name", "width": 100}, + {"label": _("Description"), "fieldname": "description", "width": 200}, + {"label": _("Item Group"), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100}, + {"label": _("Brand"), "fieldname": "brand", "fieldtype": "Link", "options": "Brand", "width": 100}, + {"label": _("Warehouse"), "fieldname": "warehouse", "fieldtype": "Link", "options": "Warehouse", "width": 120}, + {"label": _("UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 100}, + {"label": _("Actual Qty"), "fieldname": "actual_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Planned Qty"), "fieldname": "planned_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Requested Qty"), "fieldname": "indented_qty", "fieldtype": "Float", "width": 110, "convertible": "qty"}, + {"label": _("Ordered Qty"), "fieldname": "ordered_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Reserved Qty"), "fieldname": "reserved_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Reserved Qty for Production"), "fieldname": "reserved_qty_for_production", "fieldtype": "Float", + "width": 100, "convertible": "qty"}, + {"label": _("Reserved for sub contracting"), "fieldname": "reserved_qty_for_sub_contract", "fieldtype": "Float", + "width": 100, "convertible": "qty"}, + {"label": _("Projected Qty"), "fieldname": "projected_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Reorder Level"), "fieldname": "re_order_level", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Reorder Qty"), "fieldname": "re_order_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"}, + {"label": _("Shortage Qty"), "fieldname": "shortage_qty", "fieldtype": "Float", "width": 100, "convertible": "qty"} + ] def get_bin_list(filters): conditions = [] @@ -83,20 +102,29 @@ def get_bin_list(filters): return bin_list -def get_item_map(item_code): +def get_item_map(item_code, include_uom): """Optimization: get only the item doc and re_order_levels table""" condition = "" if item_code: condition = 'and item_code = "{0}"'.format(frappe.db.escape(item_code, percent=False)) - items = frappe.db.sql("""select * from `tabItem` item - where is_stock_item = 1 - and disabled=0 + cf_field = cf_join = "" + if include_uom: + cf_field = ", ucd.conversion_factor" + cf_join = "left join `tabUOM Conversion Detail` ucd on ucd.parent=item.name and ucd.uom=%(include_uom)s" + + items = frappe.db.sql(""" + select item.name, item.item_name, item.description, item.item_group, item.brand, item.stock_uom{cf_field} + from `tabItem` item + {cf_join} + where item.is_stock_item = 1 + and item.disabled=0 {condition} - and (end_of_life > %(today)s or end_of_life is null or end_of_life='0000-00-00') + and (item.end_of_life > %(today)s or item.end_of_life is null or item.end_of_life='0000-00-00') and exists (select name from `tabBin` bin where bin.item_code=item.name)"""\ - .format(condition=condition), {"today": today()}, as_dict=True) + .format(cf_field=cf_field, cf_join=cf_join, condition=condition), + {"today": today(), "include_uom": include_uom}, as_dict=True) condition = "" if item_code: diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 55078a55f7..de31c54f96 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -246,3 +246,34 @@ def validate_warehouse_company(warehouse, company): def is_group_warehouse(warehouse): if frappe.db.get_value("Warehouse", warehouse, "is_group"): frappe.throw(_("Group node warehouse is not allowed to select for transactions")) + +def update_included_uom_in_report(columns, result, include_uom, conversion_factors): + if not include_uom or not conversion_factors: + return + + convertible_cols = {} + for col_idx in reversed(range(0, len(columns))): + col = columns[col_idx] + if isinstance(col, dict) and col.get("convertible") in ['rate', 'qty']: + convertible_cols[col_idx] = col['convertible'] + columns.insert(col_idx+1, col.copy()) + columns[col_idx+1]['fieldname'] += "_alt" + if convertible_cols[col_idx] == 'rate': + columns[col_idx+1]['label'] += " (per {})".format(include_uom) + else: + columns[col_idx+1]['label'] += " ({})".format(include_uom) + + for row_idx, row in enumerate(result): + new_row = [] + for col_idx, d in enumerate(row): + new_row.append(d) + if col_idx in convertible_cols: + if conversion_factors[row_idx]: + if convertible_cols[col_idx] == 'rate': + new_row.append(flt(d) * conversion_factors[row_idx]) + else: + new_row.append(flt(d) / conversion_factors[row_idx]) + else: + new_row.append(None) + + result[row_idx] = new_row From 5d1171678e0bf7ce941a9911776e1e10f01f6049 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 19 Oct 2018 18:03:13 +0530 Subject: [PATCH 10/12] fix(perpetual inventory): Get warehouse account map only if perpetual inventory enabled (#15743) * fix(perpetual inventory): Get warehouse account map only if perpetual inventory enabled * fix(perpetual inventory): Get warehouse account map only if perpetual inventory enabled --- .../accounts/doctype/purchase_invoice/purchase_invoice.py | 6 ++++-- erpnext/stock/__init__.py | 4 ++-- erpnext/stock/doctype/warehouse/warehouse.py | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8e55514194..f5983c326f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -209,7 +209,8 @@ class PurchaseInvoice(BuyingController): if self.update_stock: self.validate_item_code() self.validate_warehouse() - warehouse_account = get_warehouse_account_map() + if auto_accounting_for_stock: + warehouse_account = get_warehouse_account_map() for item in self.get("items"): # in case of auto inventory accounting, @@ -402,7 +403,8 @@ class PurchaseInvoice(BuyingController): # item gl entries stock_items = self.get_stock_items() expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") - warehouse_account = get_warehouse_account_map() + if self.update_stock and self.auto_accounting_for_stock: + warehouse_account = get_warehouse_account_map() voucher_wise_stock_value = {} if self.update_stock: diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 06f424e5b2..ea3d1036d2 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -53,9 +53,9 @@ def get_warehouse_account(warehouse, warehouse_account=None): if not account and warehouse.company: account = get_company_default_inventory_account(warehouse.company) - if not account: + if not account and warehouse.company: frappe.throw(_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}") - .format(warehouse, warehouse.company)) + .format(warehouse.name, warehouse.company)) return account def get_company_default_inventory_account(company): diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 850d648e72..ef63740650 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -22,10 +22,11 @@ class Warehouse(NestedSet): def onload(self): '''load account name for General Ledger Report''' - account = self.account or get_warehouse_account(self) + if self.company and cint(frappe.db.get_value("Company", self.company, "enable_perpetual_inventory")): + account = self.account or get_warehouse_account(self) - if account: - self.set_onload('account', account) + if account: + self.set_onload('account', account) load_address_and_contact(self) From 24fe7286fcbab4f930df3bae76a0030c7bc8a249 Mon Sep 17 00:00:00 2001 From: Ameya Shenoy Date: Fri, 19 Oct 2018 12:48:16 +0000 Subject: [PATCH 11/12] bumped to version 10.1.61 --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index bae7915e31..d44bf40a9f 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -5,7 +5,7 @@ import frappe from erpnext.hooks import regional_overrides from frappe.utils import getdate -__version__ = '10.1.60' +__version__ = '10.1.61' def get_default_company(user=None): '''Get default company for user''' From 7e97230c920dfc7066de308c0d83511a973b5826 Mon Sep 17 00:00:00 2001 From: Ameya Shenoy Date: Fri, 19 Oct 2018 12:56:48 +0000 Subject: [PATCH 12/12] bumped to version 11.0.3-beta.13 --- erpnext/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index b26c37c23c..1d6b5e02be 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -12,7 +12,7 @@ app_license = "GNU General Public License (v3)" source_link = "https://github.com/frappe/erpnext" develop_version = '11.x.x-develop' -staging_version = '11.0.3-beta.12' +staging_version = '11.0.3-beta.13' error_report_email = "support@erpnext.com"