Merge branch 'staging' into develop
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index e61c0d1..699dcf3 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -5,7 +5,7 @@
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'''
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index f3e85b2..014efd9 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -123,7 +123,7 @@
"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
})
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 7219d87..b14597d 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -123,6 +123,7 @@
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)
@@ -208,7 +209,8 @@
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,
@@ -377,7 +379,10 @@
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,
@@ -401,7 +406,8 @@
# 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/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index d730e88..607051e 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -310,6 +310,7 @@
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)
@@ -715,7 +716,9 @@
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,
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index bf1c139..6985c80 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -70,6 +70,7 @@
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/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json
index 4070091..b0be2a9 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
+}
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 0000000..e69de29
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py
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 0000000..bfffcc5
--- /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 0000000..b156006
--- /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 0000000..29a1a2b
--- /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="<script>window.close()</script>")
+
+
+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 0000000..b71d704
--- /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 0000000..6ce7c92
--- /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
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 226a766..f242894 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -12,7 +12,7 @@
source_link = "https://github.com/frappe/erpnext"
develop_version = '12.x.x-develop'
-staging_version = '11.0.3-beta.12'
+staging_version = '11.0.3-beta.13'
error_report_email = "support@erpnext.com"
diff --git a/erpnext/manufacturing/report/production_analytics/__init__.py b/erpnext/manufacturing/report/production_analytics/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/manufacturing/report/production_analytics/__init__.py
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 0000000..b7b8f05
--- /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 0000000..023e0a8
--- /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 0000000..1dc821c
--- /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
+
+
+
+
+
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 5d6ff70..1f14e2c 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -159,7 +159,7 @@
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"]);
}
@@ -519,7 +519,7 @@
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 a27161f..3e27d56 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -286,6 +286,7 @@
me.frm.set_value("taxes", r.message.taxes);
}
},
+ () => me.set_dynamic_labels(),
() => me.calculate_taxes_and_totals()
]);
}
@@ -845,16 +846,10 @@
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) {
diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py
index 06f424e..ea3d103 100644
--- a/erpnext/stock/__init__.py
+++ b/erpnext/stock/__init__.py
@@ -53,9 +53,9 @@
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 850d648..ef63740 100644
--- a/erpnext/stock/doctype/warehouse/warehouse.py
+++ b/erpnext/stock/doctype/warehouse/warehouse.py
@@ -22,10 +22,11 @@
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)
diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js
index a563564..839ed7a 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.js
+++ b/erpnext/stock/report/stock_balance/stock_balance.js
@@ -52,6 +52,12 @@
"options": "Warehouse"
},
{
+ "fieldname":"include_uom",
+ "label": __("Include UOM"),
+ "fieldtype": "Link",
+ "options": "UOM"
+ },
+ {
"fieldname": "show_variant_attributes",
"label": __("Show Variant Attributes"),
"fieldtype": "Check"
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index b25a42a..cc1112c 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -5,6 +5,7 @@
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 @@
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 @@
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 @@
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 @@
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:
@@ -254,4 +268,4 @@
attribute_map.setdefault(attr['parent'], {})
attribute_map[attr['parent']].update({attr['attribute']: attr['attribute_value']})
- return attribute_map
\ No newline at end of file
+ return attribute_map
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js
index 660357c..3fab327 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.js
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.js
@@ -70,6 +70,12 @@
"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 dc3cb28..805b314 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 @@
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 @@
.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 @@
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 51b9b0c..6589688 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 @@
"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 885ab78..913d7d8 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 @@
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 @@
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 @@
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 55078a5..de31c54 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -246,3 +246,34 @@
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