@@ -68,6 +64,7 @@ frappe.ui.form.on('Accounting Dimension Filter', {
frm.clear_table("dimensions");
let row = frm.add_child("dimensions");
row.accounting_dimension = frm.doc.accounting_dimension;
+ frm.fields_dict["dimensions"].grid.update_docfield_property("dimension_value", "label", frm.doc.accounting_dimension);
frm.refresh_field("dimensions");
frm.trigger('setup_filters');
},
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 3e0b82c561..3f985b640b 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -56,7 +56,9 @@
"acc_frozen_upto",
"column_break_25",
"frozen_accounts_modifier",
- "report_settings_sb"
+ "report_settings_sb",
+ "tab_break_dpet",
+ "show_balance_in_coa"
],
"fields": [
{
@@ -91,7 +93,7 @@
},
{
"default": "0",
- "description": "Enabling ensure each Sales Invoice has a unique value in Supplier Invoice No. field",
+ "description": "Enabling ensure each Purchase Invoice has a unique value in Supplier Invoice No. field",
"fieldname": "check_supplier_invoice_uniqueness",
"fieldtype": "Check",
"label": "Check Supplier Invoice Number Uniqueness"
@@ -347,6 +349,17 @@
"fieldname": "allow_multi_currency_invoices_against_single_party_account",
"fieldtype": "Check",
"label": "Allow multi-currency invoices against single party account "
+ },
+ {
+ "fieldname": "tab_break_dpet",
+ "fieldtype": "Tab Break",
+ "label": "Chart Of Accounts"
+ },
+ {
+ "default": "1",
+ "fieldname": "show_balance_in_coa",
+ "fieldtype": "Check",
+ "label": "Show Balances in Chart Of Accounts"
}
],
"icon": "icon-cog",
@@ -354,7 +367,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-07-11 13:37:50.605141",
+ "modified": "2023-01-02 12:07:42.434214",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js
index 059e1d3158..35d606ba3a 100644
--- a/erpnext/accounts/doctype/bank/bank.js
+++ b/erpnext/accounts/doctype/bank/bank.js
@@ -118,6 +118,10 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
}
plaid_success(token, response) {
- frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' });
+ frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.update_bank_account_ids', {
+ response: response,
+ }).then(() => {
+ frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' });
+ });
}
};
diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py
index addcf62e5b..b91f0f9137 100644
--- a/erpnext/accounts/doctype/bank_account/bank_account.py
+++ b/erpnext/accounts/doctype/bank_account/bank_account.py
@@ -77,6 +77,6 @@ def get_party_bank_account(party_type, party):
@frappe.whitelist()
def get_bank_account_details(bank_account):
- return frappe.db.get_value(
+ return frappe.get_cached_value(
"Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1
)
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
index 63cc46518f..71f2dcca1b 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
@@ -4,6 +4,23 @@
frappe.ui.form.on("Bank Clearance", {
setup: function(frm) {
frm.add_fetch("account", "account_currency", "account_currency");
+
+ frm.set_query("account", function() {
+ return {
+ "filters": {
+ "account_type": ["in",["Bank","Cash"]],
+ "is_group": 0,
+ }
+ };
+ });
+
+ frm.set_query("bank_account", function () {
+ return {
+ filters: {
+ 'is_company_account': 1
+ },
+ };
+ });
},
onload: function(frm) {
@@ -12,14 +29,7 @@ frappe.ui.form.on("Bank Clearance", {
locals[":Company"][frappe.defaults.get_user_default("Company")]["default_bank_account"]: "";
frm.set_value("account", default_bank_account);
- frm.set_query("account", function() {
- return {
- "filters": {
- "account_type": ["in",["Bank","Cash"]],
- "is_group": 0
- }
- };
- });
+
frm.set_value("from_date", frappe.datetime.month_start());
frm.set_value("to_date", frappe.datetime.month_end());
@@ -27,6 +37,11 @@ frappe.ui.form.on("Bank Clearance", {
refresh: function(frm) {
frm.disable_save();
+ frm.add_custom_button(__('Get Payment Entries'), () =>
+ frm.trigger("get_payment_entries")
+ );
+
+ frm.change_custom_button_type('Get Payment Entries', null, 'primary');
},
update_clearance_date: function(frm) {
@@ -36,22 +51,30 @@ frappe.ui.form.on("Bank Clearance", {
callback: function(r, rt) {
frm.refresh_field("payment_entries");
frm.refresh_fields();
+
+ if (!frm.doc.payment_entries.length) {
+ frm.change_custom_button_type('Get Payment Entries', null, 'primary');
+ frm.change_custom_button_type('Update Clearance Date', null, 'default');
+ }
}
});
},
+
get_payment_entries: function(frm) {
return frappe.call({
method: "get_payment_entries",
doc: frm.doc,
callback: function(r, rt) {
frm.refresh_field("payment_entries");
- frm.refresh_fields();
- $(frm.fields_dict.payment_entries.wrapper).find("[data-fieldname=amount]").each(function(i,v){
- if (i !=0){
- $(v).addClass("text-right")
- }
- })
+ if (frm.doc.payment_entries.length) {
+ frm.add_custom_button(__('Update Clearance Date'), () =>
+ frm.trigger("update_clearance_date")
+ );
+
+ frm.change_custom_button_type('Get Payment Entries', null, 'default');
+ frm.change_custom_button_type('Update Clearance Date', null, 'primary');
+ }
}
});
}
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.json b/erpnext/accounts/doctype/bank_clearance/bank_clearance.json
index a436d1effb..591d01949b 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.json
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"allow_copy": 1,
"creation": "2013-01-10 16:34:05",
"doctype": "DocType",
@@ -13,11 +14,8 @@
"bank_account",
"include_reconciled_entries",
"include_pos_transactions",
- "get_payment_entries",
"section_break_10",
- "payment_entries",
- "update_clearance_date",
- "total_amount"
+ "payment_entries"
],
"fields": [
{
@@ -76,11 +74,6 @@
"fieldtype": "Check",
"label": "Include POS Transactions"
},
- {
- "fieldname": "get_payment_entries",
- "fieldtype": "Button",
- "label": "Get Payment Entries"
- },
{
"fieldname": "section_break_10",
"fieldtype": "Section Break"
@@ -91,25 +84,14 @@
"fieldtype": "Table",
"label": "Payment Entries",
"options": "Bank Clearance Detail"
- },
- {
- "fieldname": "update_clearance_date",
- "fieldtype": "Button",
- "label": "Update Clearance Date"
- },
- {
- "fieldname": "total_amount",
- "fieldtype": "Currency",
- "label": "Total Amount",
- "options": "account_currency",
- "read_only": 1
}
],
"hide_toolbar": 1,
"icon": "fa fa-check",
"idx": 1,
"issingle": 1,
- "modified": "2020-04-06 16:12:06.628008",
+ "links": [],
+ "modified": "2022-11-28 17:24:13.008692",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Clearance",
@@ -126,5 +108,6 @@
"quick_entry": 1,
"read_only": 1,
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
index 1a572d9823..80878ac506 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
@@ -99,7 +99,7 @@ class BankClearance(Document):
.where(loan_disbursement.clearance_date.isnull())
.where(loan_disbursement.disbursement_account.isin([self.bank_account, self.account]))
.orderby(loan_disbursement.disbursement_date)
- .orderby(loan_disbursement.name, frappe.qb.desc)
+ .orderby(loan_disbursement.name, order=frappe.qb.desc)
).run(as_dict=1)
loan_repayment = frappe.qb.DocType("Loan Repayment")
@@ -126,7 +126,9 @@ class BankClearance(Document):
if frappe.db.has_column("Loan Repayment", "repay_from_salary"):
query = query.where((loan_repayment.repay_from_salary == 0))
- query = query.orderby(loan_repayment.posting_date).orderby(loan_repayment.name, frappe.qb.desc)
+ query = query.orderby(loan_repayment.posting_date).orderby(
+ loan_repayment.name, order=frappe.qb.desc
+ )
loan_repayments = query.run(as_dict=True)
@@ -177,7 +179,6 @@ class BankClearance(Document):
)
self.set("payment_entries", [])
- self.total_amount = 0.0
default_currency = erpnext.get_default_currency()
for d in entries:
@@ -196,7 +197,6 @@ class BankClearance(Document):
d.pop("debit")
d.pop("account_currency")
row.update(d)
- self.total_amount += flt(amount)
@frappe.whitelist()
def update_clearance_date(self):
diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
index febf85ca6c..99cc0a72fb 100644
--- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
+++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
@@ -43,20 +43,13 @@ frappe.ui.form.on('Bank Guarantee', {
reference_docname: function(frm) {
if (frm.doc.reference_docname && frm.doc.reference_doctype) {
- let fields_to_fetch = ["grand_total"];
let party_field = frm.doc.reference_doctype == "Sales Order" ? "customer" : "supplier";
- if (frm.doc.reference_doctype == "Sales Order") {
- fields_to_fetch.push("project");
- }
-
- fields_to_fetch.push(party_field);
frappe.call({
- method: "erpnext.accounts.doctype.bank_guarantee.bank_guarantee.get_vouchar_detials",
+ method: "erpnext.accounts.doctype.bank_guarantee.bank_guarantee.get_voucher_details",
args: {
- "column_list": fields_to_fetch,
- "doctype": frm.doc.reference_doctype,
- "docname": frm.doc.reference_docname
+ "bank_guarantee_type": frm.doc.bg_type,
+ "reference_name": frm.doc.reference_docname
},
callback: function(r) {
if (r.message) {
diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.py b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.py
index 9144a29c6e..02eb599acc 100644
--- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.py
+++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.py
@@ -2,11 +2,8 @@
# For license information, please see license.txt
-import json
-
import frappe
from frappe import _
-from frappe.desk.search import sanitize_searchfield
from frappe.model.document import Document
@@ -25,14 +22,18 @@ class BankGuarantee(Document):
@frappe.whitelist()
-def get_vouchar_detials(column_list, doctype, docname):
- column_list = json.loads(column_list)
- for col in column_list:
- sanitize_searchfield(col)
- return frappe.db.sql(
- """ select {columns} from `tab{doctype}` where name=%s""".format(
- columns=", ".join(column_list), doctype=doctype
- ),
- docname,
- as_dict=1,
- )[0]
+def get_voucher_details(bank_guarantee_type: str, reference_name: str):
+ if not isinstance(reference_name, str):
+ raise TypeError("reference_name must be a string")
+
+ fields_to_fetch = ["grand_total"]
+
+ if bank_guarantee_type == "Receiving":
+ doctype = "Sales Order"
+ fields_to_fetch.append("customer")
+ fields_to_fetch.append("project")
+ else:
+ doctype = "Purchase Order"
+ fields_to_fetch.append("supplier")
+
+ return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
index 46ba27c004..ae84154f2d 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
@@ -12,19 +12,31 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
},
};
});
+ let no_bank_transactions_text =
+ ` ${__("No Matching Bank Transactions Found")} `
+ set_field_options("no_bank_transactions", no_bank_transactions_text);
},
onload: function (frm) {
frm.trigger('bank_account');
},
+ filter_by_reference_date: function (frm) {
+ if (frm.doc.filter_by_reference_date) {
+ frm.set_value("bank_statement_from_date", "");
+ frm.set_value("bank_statement_to_date", "");
+ } else {
+ frm.set_value("from_reference_date", "");
+ frm.set_value("to_reference_date", "");
+ }
+ },
+
refresh: function (frm) {
frappe.require("bank-reconciliation-tool.bundle.js", () =>
frm.trigger("make_reconciliation_tool")
);
- frm.upload_statement_button = frm.page.set_secondary_action(
- __("Upload Bank Statement"),
- () =>
+
+ frm.add_custom_button(__("Upload Bank Statement"), () =>
frappe.call({
method:
"erpnext.accounts.doctype.bank_statement_import.bank_statement_import.upload_bank_statement",
@@ -46,6 +58,20 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
},
})
);
+
+ frm.add_custom_button(__('Auto Reconcile'), function() {
+ frappe.call({
+ method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
+ args: {
+ bank_account: frm.doc.bank_account,
+ from_date: frm.doc.bank_statement_from_date,
+ to_date: frm.doc.bank_statement_to_date,
+ filter_by_reference_date: frm.doc.filter_by_reference_date,
+ from_reference_date: frm.doc.from_reference_date,
+ to_reference_date: frm.doc.to_reference_date,
+ },
+ })
+ });
},
after_save: function (frm) {
@@ -129,7 +155,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
}
},
- render_chart: frappe.utils.debounce((frm) => {
+ render_chart(frm) {
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
{
$reconciliation_tool_cards: frm.get_field(
@@ -141,7 +167,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
currency: frm.currency,
}
);
- }, 500),
+ },
render(frm) {
if (frm.doc.bank_account) {
@@ -157,6 +183,9 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
).$wrapper,
bank_statement_from_date: frm.doc.bank_statement_from_date,
bank_statement_to_date: frm.doc.bank_statement_to_date,
+ filter_by_reference_date: frm.doc.filter_by_reference_date,
+ from_reference_date: frm.doc.from_reference_date,
+ to_reference_date: frm.doc.to_reference_date,
bank_statement_closing_balance:
frm.doc.bank_statement_closing_balance,
cards_manager: frm.cards_manager,
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json
index b643e6e091..80993d6608 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json
@@ -10,6 +10,9 @@
"column_break_1",
"bank_statement_from_date",
"bank_statement_to_date",
+ "from_reference_date",
+ "to_reference_date",
+ "filter_by_reference_date",
"column_break_2",
"account_opening_balance",
"bank_statement_closing_balance",
@@ -36,13 +39,13 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "eval: doc.bank_account",
+ "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date",
"fieldname": "bank_statement_from_date",
"fieldtype": "Date",
"label": "From Date"
},
{
- "depends_on": "eval: doc.bank_statement_from_date",
+ "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date",
"fieldname": "bank_statement_to_date",
"fieldtype": "Date",
"label": "To Date"
@@ -83,13 +86,31 @@
"fieldname": "no_bank_transactions",
"fieldtype": "HTML",
"options": "No Matching Bank Transactions Found "
+ },
+ {
+ "depends_on": "eval:doc.filter_by_reference_date",
+ "fieldname": "from_reference_date",
+ "fieldtype": "Date",
+ "label": "From Reference Date"
+ },
+ {
+ "depends_on": "eval:doc.filter_by_reference_date",
+ "fieldname": "to_reference_date",
+ "fieldtype": "Date",
+ "label": "To Reference Date"
+ },
+ {
+ "default": "0",
+ "fieldname": "filter_by_reference_date",
+ "fieldtype": "Check",
+ "label": "Filter by Reference Date"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-04-21 11:13:49.831769",
+ "modified": "2023-01-13 13:00:02.022919",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Reconciliation Tool",
@@ -108,5 +129,6 @@
],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index cc3727ce83..c4a23a640c 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -8,9 +8,9 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
-from frappe.utils import flt
+from frappe.utils import cint, flt
-from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount
+from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import (
get_amounts_not_reflected_in_system,
get_entries,
@@ -28,7 +28,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
filters = []
filters.append(["bank_account", "=", bank_account])
filters.append(["docstatus", "=", 1])
- filters.append(["unallocated_amount", ">", 0])
+ filters.append(["unallocated_amount", ">", 0.0])
if to_date:
filters.append(["date", "<=", to_date])
if from_date:
@@ -50,6 +50,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
"party",
],
filters=filters,
+ order_by="date",
)
return transactions
@@ -65,7 +66,7 @@ def get_account_balance(bank_account, till_date):
balance_as_per_system = get_balance_on(filters["account"], filters["report_date"])
- total_debit, total_credit = 0, 0
+ total_debit, total_credit = 0.0, 0.0
for d in data:
total_debit += flt(d.debit)
total_credit += flt(d.credit)
@@ -144,10 +145,8 @@ def create_journal_entry_bts(
accounts.append(
{
"account": second_account,
- "credit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0,
- "debit_in_account_currency": bank_transaction.withdrawal
- if bank_transaction.withdrawal > 0
- else 0,
+ "credit_in_account_currency": bank_transaction.deposit,
+ "debit_in_account_currency": bank_transaction.withdrawal,
"party_type": party_type,
"party": party,
}
@@ -157,10 +156,8 @@ def create_journal_entry_bts(
{
"account": company_account,
"bank_account": bank_transaction.bank_account,
- "credit_in_account_currency": bank_transaction.withdrawal
- if bank_transaction.withdrawal > 0
- else 0,
- "debit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0,
+ "credit_in_account_currency": bank_transaction.withdrawal,
+ "debit_in_account_currency": bank_transaction.deposit,
}
)
@@ -184,16 +181,22 @@ def create_journal_entry_bts(
journal_entry.insert()
journal_entry.submit()
- if bank_transaction.deposit > 0:
+ if bank_transaction.deposit > 0.0:
paid_amount = bank_transaction.deposit
else:
paid_amount = bank_transaction.withdrawal
vouchers = json.dumps(
- [{"payment_doctype": "Journal Entry", "payment_name": journal_entry.name, "amount": paid_amount}]
+ [
+ {
+ "payment_doctype": "Journal Entry",
+ "payment_name": journal_entry.name,
+ "amount": paid_amount,
+ }
+ ]
)
- return reconcile_vouchers(bank_transaction.name, vouchers)
+ return reconcile_vouchers(bank_transaction_name, vouchers)
@frappe.whitelist()
@@ -217,7 +220,7 @@ def create_payment_entry_bts(
as_dict=True,
)[0]
paid_amount = bank_transaction.unallocated_amount
- payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay"
+ payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay"
company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_value("Account", company_account, "company")
@@ -256,9 +259,89 @@ def create_payment_entry_bts(
payment_entry.submit()
vouchers = json.dumps(
- [{"payment_doctype": "Payment Entry", "payment_name": payment_entry.name, "amount": paid_amount}]
+ [
+ {
+ "payment_doctype": "Payment Entry",
+ "payment_name": payment_entry.name,
+ "amount": paid_amount,
+ }
+ ]
)
- return reconcile_vouchers(bank_transaction.name, vouchers)
+ return reconcile_vouchers(bank_transaction_name, vouchers)
+
+
+@frappe.whitelist()
+def auto_reconcile_vouchers(
+ bank_account,
+ from_date=None,
+ to_date=None,
+ filter_by_reference_date=None,
+ from_reference_date=None,
+ to_reference_date=None,
+):
+ frappe.flags.auto_reconcile_vouchers = True
+ document_types = ["payment_entry", "journal_entry"]
+ bank_transactions = get_bank_transactions(bank_account)
+ matched_transaction = []
+ for transaction in bank_transactions:
+ linked_payments = get_linked_payments(
+ transaction.name,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ )
+ vouchers = []
+ for r in linked_payments:
+ vouchers.append(
+ {
+ "payment_doctype": r[1],
+ "payment_name": r[2],
+ "amount": r[4],
+ }
+ )
+ transaction = frappe.get_doc("Bank Transaction", transaction.name)
+ account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
+ matched_trans = 0
+ for voucher in vouchers:
+ gl_entry = frappe.db.get_value(
+ "GL Entry",
+ dict(
+ account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"]
+ ),
+ ["credit", "debit"],
+ as_dict=1,
+ )
+ gl_amount, transaction_amount = (
+ (gl_entry.credit, transaction.deposit)
+ if gl_entry.credit > 0
+ else (gl_entry.debit, transaction.withdrawal)
+ )
+ allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount
+ transaction.append(
+ "payment_entries",
+ {
+ "payment_document": voucher["payment_doctype"],
+ "payment_entry": voucher["payment_name"],
+ "allocated_amount": allocated_amount,
+ },
+ )
+ matched_transaction.append(str(transaction.name))
+ transaction.save()
+ transaction.update_allocations()
+ matched_transaction_len = len(set(matched_transaction))
+ if matched_transaction_len == 0:
+ frappe.msgprint(_("No matching references found for auto reconciliation"))
+ elif matched_transaction_len == 1:
+ frappe.msgprint(_("{0} transaction is reconcilied").format(matched_transaction_len))
+ else:
+ frappe.msgprint(_("{0} transactions are reconcilied").format(matched_transaction_len))
+
+ frappe.flags.auto_reconcile_vouchers = False
+
+ return frappe.get_doc("Bank Transaction", transaction.name)
@frappe.whitelist()
@@ -266,80 +349,88 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
# updated clear date of all the vouchers based on the bank transaction
vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
- company_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
-
- if transaction.unallocated_amount == 0:
- frappe.throw(_("This bank transaction is already fully reconciled"))
- total_amount = 0
- for voucher in vouchers:
- voucher["payment_entry"] = frappe.get_doc(voucher["payment_doctype"], voucher["payment_name"])
- total_amount += get_paid_amount(
- frappe._dict(
- {
- "payment_document": voucher["payment_doctype"],
- "payment_entry": voucher["payment_name"],
- }
- ),
- transaction.currency,
- company_account,
- )
-
- if total_amount > transaction.unallocated_amount:
- frappe.throw(
- _(
- "The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction"
- )
- )
- account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
-
- for voucher in vouchers:
- gl_entry = frappe.db.get_value(
- "GL Entry",
- dict(
- account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"]
- ),
- ["credit", "debit"],
- as_dict=1,
- )
- gl_amount, transaction_amount = (
- (gl_entry.credit, transaction.deposit)
- if gl_entry.credit > 0
- else (gl_entry.debit, transaction.withdrawal)
- )
- allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount
-
- transaction.append(
- "payment_entries",
- {
- "payment_document": voucher["payment_entry"].doctype,
- "payment_entry": voucher["payment_entry"].name,
- "allocated_amount": allocated_amount,
- },
- )
-
- transaction.save()
- transaction.update_allocations()
+ transaction.add_payment_entries(vouchers)
return frappe.get_doc("Bank Transaction", bank_transaction_name)
@frappe.whitelist()
-def get_linked_payments(bank_transaction_name, document_types=None):
+def get_linked_payments(
+ bank_transaction_name,
+ document_types=None,
+ from_date=None,
+ to_date=None,
+ filter_by_reference_date=None,
+ from_reference_date=None,
+ to_reference_date=None,
+):
# get all matching payments for a bank transaction
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_account = frappe.db.get_values(
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
)[0]
- (account, company) = (bank_account.account, bank_account.company)
- matching = check_matching(account, company, transaction, document_types)
- return matching
+ (gl_account, company) = (bank_account.account, bank_account.company)
+ matching = check_matching(
+ gl_account,
+ company,
+ transaction,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ )
+ return subtract_allocations(gl_account, matching)
-def check_matching(bank_account, company, transaction, document_types):
+def subtract_allocations(gl_account, vouchers):
+ "Look up & subtract any existing Bank Transaction allocations"
+ copied = []
+ for voucher in vouchers:
+ rows = get_total_allocated_amount(voucher[1], voucher[2])
+ amount = None
+ for row in rows:
+ if row["gl_account"] == gl_account:
+ amount = row["total"]
+ break
+
+ if amount:
+ l = list(voucher)
+ l[3] -= amount
+ copied.append(tuple(l))
+ else:
+ copied.append(voucher)
+ return copied
+
+
+def check_matching(
+ bank_account,
+ company,
+ transaction,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+):
+ exact_match = True if "exact_match" in document_types else False
# combine all types of vouchers
- subquery = get_queries(bank_account, company, transaction, document_types)
+ subquery = get_queries(
+ bank_account,
+ company,
+ transaction,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ exact_match,
+ )
filters = {
"amount": transaction.unallocated_amount,
- "payment_type": "Receive" if transaction.deposit > 0 else "Pay",
+ "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay",
"reference_no": transaction.reference_number,
"party_type": transaction.party_type,
"party": transaction.party,
@@ -348,7 +439,9 @@ def check_matching(bank_account, company, transaction, document_types):
matching_vouchers = []
- matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters))
+ matching_vouchers.extend(
+ get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match)
+ )
for query in subquery:
matching_vouchers.extend(
@@ -357,14 +450,23 @@ def check_matching(bank_account, company, transaction, document_types):
filters,
)
)
-
return sorted(matching_vouchers, key=lambda x: x[0], reverse=True) if matching_vouchers else []
-def get_queries(bank_account, company, transaction, document_types):
+def get_queries(
+ bank_account,
+ company,
+ transaction,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ exact_match,
+):
# get queries to get matching vouchers
- amount_condition = "=" if "exact_match" in document_types else "<="
- account_from_to = "paid_to" if transaction.deposit > 0 else "paid_from"
+ account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from"
queries = []
# get matching queries from all the apps
@@ -375,8 +477,13 @@ def get_queries(bank_account, company, transaction, document_types):
company,
transaction,
document_types,
- amount_condition,
+ exact_match,
account_from_to,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
)
or []
)
@@ -385,43 +492,106 @@ def get_queries(bank_account, company, transaction, document_types):
def get_matching_queries(
- bank_account, company, transaction, document_types, amount_condition, account_from_to
+ bank_account,
+ company,
+ transaction,
+ document_types,
+ exact_match,
+ account_from_to,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
):
queries = []
if "payment_entry" in document_types:
- pe_amount_matching = get_pe_matching_query(amount_condition, account_from_to, transaction)
- queries.extend([pe_amount_matching])
+ query = get_pe_matching_query(
+ exact_match,
+ account_from_to,
+ transaction,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ )
+ queries.append(query)
if "journal_entry" in document_types:
- je_amount_matching = get_je_matching_query(amount_condition, transaction)
- queries.extend([je_amount_matching])
+ query = get_je_matching_query(
+ exact_match,
+ transaction,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ )
+ queries.append(query)
- if transaction.deposit > 0 and "sales_invoice" in document_types:
- si_amount_matching = get_si_matching_query(amount_condition)
- queries.extend([si_amount_matching])
+ if transaction.deposit > 0.0 and "sales_invoice" in document_types:
+ query = get_si_matching_query(exact_match)
+ queries.append(query)
- if transaction.withdrawal > 0:
+ if transaction.withdrawal > 0.0:
if "purchase_invoice" in document_types:
- pi_amount_matching = get_pi_matching_query(amount_condition)
- queries.extend([pi_amount_matching])
+ query = get_pi_matching_query(exact_match)
+ queries.append(query)
+
+ if "bank_transaction" in document_types:
+ query = get_bt_matching_query(exact_match, transaction)
+ queries.append(query)
return queries
-def get_loan_vouchers(bank_account, transaction, document_types, filters):
+def get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match):
vouchers = []
- amount_condition = True if "exact_match" in document_types else False
- if transaction.withdrawal > 0 and "loan_disbursement" in document_types:
- vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters))
+ if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types:
+ vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters))
- if transaction.deposit > 0 and "loan_repayment" in document_types:
- vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters))
+ if transaction.deposit > 0.0 and "loan_repayment" in document_types:
+ vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters))
return vouchers
-def get_ld_matching_query(bank_account, amount_condition, filters):
+def get_bt_matching_query(exact_match, transaction):
+ # get matching bank transaction query
+ # find bank transactions in the same bank account with opposite sign
+ # same bank account must have same company and currency
+ field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal"
+
+ return f"""
+
+ SELECT
+ (CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END
+ + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END
+ + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
+ + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END
+ + 1) AS rank,
+ 'Bank Transaction' AS doctype,
+ name,
+ unallocated_amount AS paid_amount,
+ reference_number AS reference_no,
+ date AS reference_date,
+ party,
+ party_type,
+ date AS posting_date,
+ currency
+ FROM
+ `tabBank Transaction`
+ WHERE
+ status != 'Reconciled'
+ AND name != '{transaction.name}'
+ AND bank_account = '{transaction.bank_account}'
+ AND {field} {'= %(amount)s' if exact_match else '> 0.0'}
+ """
+
+
+def get_ld_matching_query(bank_account, exact_match, filters):
loan_disbursement = frappe.qb.DocType("Loan Disbursement")
matching_reference = loan_disbursement.reference_number == filters.get("reference_number")
matching_party = loan_disbursement.applicant_type == filters.get(
@@ -449,17 +619,17 @@ def get_ld_matching_query(bank_account, amount_condition, filters):
.where(loan_disbursement.disbursement_account == bank_account)
)
- if amount_condition:
+ if exact_match:
query.where(loan_disbursement.disbursed_amount == filters.get("amount"))
else:
- query.where(loan_disbursement.disbursed_amount <= filters.get("amount"))
+ query.where(loan_disbursement.disbursed_amount > 0.0)
vouchers = query.run(as_list=True)
return vouchers
-def get_lr_matching_query(bank_account, amount_condition, filters):
+def get_lr_matching_query(bank_account, exact_match, filters):
loan_repayment = frappe.qb.DocType("Loan Repayment")
matching_reference = loan_repayment.reference_number == filters.get("reference_number")
matching_party = loan_repayment.applicant_type == filters.get(
@@ -490,88 +660,129 @@ def get_lr_matching_query(bank_account, amount_condition, filters):
if frappe.db.has_column("Loan Repayment", "repay_from_salary"):
query = query.where((loan_repayment.repay_from_salary == 0))
- if amount_condition:
+ if exact_match:
query.where(loan_repayment.amount_paid == filters.get("amount"))
else:
- query.where(loan_repayment.amount_paid <= filters.get("amount"))
+ query.where(loan_repayment.amount_paid > 0.0)
vouchers = query.run()
return vouchers
-def get_pe_matching_query(amount_condition, account_from_to, transaction):
+def get_pe_matching_query(
+ exact_match,
+ account_from_to,
+ transaction,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+):
# get matching payment entries query
- if transaction.deposit > 0:
+ if transaction.deposit > 0.0:
currency_field = "paid_to_account_currency as currency"
else:
currency_field = "paid_from_account_currency as currency"
+ filter_by_date = f"AND posting_date between '{from_date}' and '{to_date}'"
+ order_by = " posting_date"
+ filter_by_reference_no = ""
+ if cint(filter_by_reference_date):
+ filter_by_date = f"AND reference_date between '{from_reference_date}' and '{to_reference_date}'"
+ order_by = " reference_date"
+ if frappe.flags.auto_reconcile_vouchers == True:
+ filter_by_reference_no = f"AND reference_no = '{transaction.reference_number}'"
return f"""
- SELECT
- (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END
- + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
- + 1 ) AS rank,
- 'Payment Entry' as doctype,
- name,
- paid_amount,
- reference_no,
- reference_date,
- party,
- party_type,
- posting_date,
- {currency_field}
- FROM
- `tabPayment Entry`
- WHERE
- paid_amount {amount_condition} %(amount)s
- AND docstatus = 1
- AND payment_type IN (%(payment_type)s, 'Internal Transfer')
- AND ifnull(clearance_date, '') = ""
- AND {account_from_to} = %(bank_account)s
+ SELECT
+ (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END
+ + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
+ + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
+ + 1 ) AS rank,
+ 'Payment Entry' as doctype,
+ name,
+ paid_amount,
+ reference_no,
+ reference_date,
+ party,
+ party_type,
+ posting_date,
+ {currency_field}
+ FROM
+ `tabPayment Entry`
+ WHERE
+ docstatus = 1
+ AND payment_type IN (%(payment_type)s, 'Internal Transfer')
+ AND ifnull(clearance_date, '') = ""
+ AND {account_from_to} = %(bank_account)s
+ AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
+ {filter_by_date}
+ {filter_by_reference_no}
+ order by{order_by}
"""
-def get_je_matching_query(amount_condition, transaction):
+def get_je_matching_query(
+ exact_match,
+ transaction,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+):
# get matching journal entry query
-
# We have mapping at the bank level
# So one bank could have both types of bank accounts like asset and liability
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
- cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
-
+ cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit"
+ filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'"
+ order_by = " je.posting_date"
+ filter_by_reference_no = ""
+ if cint(filter_by_reference_date):
+ filter_by_date = f"AND je.cheque_date between '{from_reference_date}' and '{to_reference_date}'"
+ order_by = " je.cheque_date"
+ if frappe.flags.auto_reconcile_vouchers == True:
+ filter_by_reference_no = f"AND je.cheque_no = '{transaction.reference_number}'"
return f"""
-
SELECT
(CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END
+ + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END
+ 1) AS rank ,
- 'Journal Entry' as doctype,
+ 'Journal Entry' AS doctype,
je.name,
- jea.{cr_or_dr}_in_account_currency as paid_amount,
- je.cheque_no as reference_no,
- je.cheque_date as reference_date,
- je.pay_to_recd_from as party,
+ jea.{cr_or_dr}_in_account_currency AS paid_amount,
+ je.cheque_no AS reference_no,
+ je.cheque_date AS reference_date,
+ je.pay_to_recd_from AS party,
jea.party_type,
je.posting_date,
- jea.account_currency as currency
+ jea.account_currency AS currency
FROM
- `tabJournal Entry Account` as jea
+ `tabJournal Entry Account` AS jea
JOIN
- `tabJournal Entry` as je
+ `tabJournal Entry` AS je
ON
jea.parent = je.name
WHERE
- (je.clearance_date is null or je.clearance_date='0000-00-00')
+ je.docstatus = 1
+ AND je.voucher_type NOT IN ('Opening Entry')
+ AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00')
AND jea.account = %(bank_account)s
- AND jea.{cr_or_dr}_in_account_currency {amount_condition} %(amount)s
+ AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'}
AND je.docstatus = 1
+ {filter_by_date}
+ {filter_by_reference_no}
+ order by {order_by}
"""
-def get_si_matching_query(amount_condition):
- # get matchin sales invoice query
+def get_si_matching_query(exact_match):
+ # get matching sales invoice query
return f"""
SELECT
- ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END
+ ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END
+ + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END
+ 1 ) AS rank,
'Sales Invoice' as doctype,
si.name,
@@ -589,18 +800,20 @@ def get_si_matching_query(amount_condition):
`tabSales Invoice` as si
ON
sip.parent = si.name
- WHERE (sip.clearance_date is null or sip.clearance_date='0000-00-00')
+ WHERE
+ si.docstatus = 1
+ AND (sip.clearance_date is null or sip.clearance_date='0000-00-00')
AND sip.account = %(bank_account)s
- AND sip.amount {amount_condition} %(amount)s
- AND si.docstatus = 1
+ AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'}
"""
-def get_pi_matching_query(amount_condition):
- # get matching purchase invoice query
+def get_pi_matching_query(exact_match):
+ # get matching purchase invoice query when they are also used as payment entries (is_paid)
return f"""
SELECT
( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END
+ + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
+ 1 ) AS rank,
'Purchase Invoice' as doctype,
name,
@@ -614,9 +827,9 @@ def get_pi_matching_query(amount_condition):
FROM
`tabPurchase Invoice`
WHERE
- paid_amount {amount_condition} %(amount)s
- AND docstatus = 1
+ docstatus = 1
AND is_paid = 1
AND ifnull(clearance_date, '') = ""
- AND cash_bank_account = %(bank_account)s
+ AND cash_bank_account = %(bank_account)s
+ AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
"""
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
index a964965c26..04af32346b 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
@@ -100,7 +100,7 @@ frappe.ui.form.on("Bank Statement Import", {
if (frm.doc.status.includes("Success")) {
frm.add_custom_button(
- __("Go to {0} List", [frm.doc.reference_doctype]),
+ __("Go to {0} List", [__(frm.doc.reference_doctype)]),
() => frappe.set_route("List", frm.doc.reference_doctype)
);
}
@@ -141,7 +141,7 @@ frappe.ui.form.on("Bank Statement Import", {
},
show_import_status(frm) {
- let import_log = JSON.parse(frm.doc.import_log || "[]");
+ let import_log = JSON.parse(frm.doc.statement_import_log || "[]");
let successful_records = import_log.filter((log) => log.success);
let failed_records = import_log.filter((log) => !log.success);
if (successful_records.length === 0) return;
@@ -309,7 +309,7 @@ frappe.ui.form.on("Bank Statement Import", {
// method: 'frappe.core.doctype.data_import.data_import.get_preview_from_template',
show_import_preview(frm, preview_data) {
- let import_log = JSON.parse(frm.doc.import_log || "[]");
+ let import_log = JSON.parse(frm.doc.statement_import_log || "[]");
if (
frm.import_preview &&
@@ -439,7 +439,7 @@ frappe.ui.form.on("Bank Statement Import", {
},
show_import_log(frm) {
- let import_log = JSON.parse(frm.doc.import_log || "[]");
+ let import_log = JSON.parse(frm.doc.statement_import_log || "[]");
let logs = import_log;
frm.toggle_display("import_log", false);
frm.toggle_display("import_log_section", logs.length > 0);
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json
index 7ffff02850..eede3bdc6d 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json
@@ -24,7 +24,7 @@
"section_import_preview",
"import_preview",
"import_log_section",
- "import_log",
+ "statement_import_log",
"show_failed_logs",
"import_log_preview",
"reference_doctype",
@@ -90,12 +90,6 @@
"options": "JSON",
"read_only": 1
},
- {
- "fieldname": "import_log",
- "fieldtype": "Code",
- "label": "Import Log",
- "options": "JSON"
- },
{
"fieldname": "import_log_section",
"fieldtype": "Section Break",
@@ -198,11 +192,17 @@
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "statement_import_log",
+ "fieldtype": "Code",
+ "label": "Statement Import Log",
+ "options": "JSON"
}
],
"hide_toolbar": 1,
"links": [],
- "modified": "2021-05-12 14:17:37.777246",
+ "modified": "2022-09-07 11:11:40.293317",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
index 3f5c064f4b..d8880f7041 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
@@ -53,15 +53,13 @@ class BankStatementImport(DataImport):
if "Bank Account" not in json.dumps(preview["columns"]):
frappe.throw(_("Please add the Bank Account column"))
- from frappe.core.page.background_jobs.background_jobs import get_info
+ from frappe.utils.background_jobs import is_job_queued
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
-
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_import,
queue="default",
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
index 6f2900a680..e548b4c7e9 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
@@ -12,8 +12,13 @@ frappe.ui.form.on("Bank Transaction", {
};
});
},
-
- bank_account: function(frm) {
+ refresh(frm) {
+ frm.add_custom_button(__('Unreconcile Transaction'), () => {
+ frm.call('remove_payment_entries')
+ .then( () => frm.refresh() );
+ });
+ },
+ bank_account: function (frm) {
set_bank_statement_filter(frm);
},
@@ -34,6 +39,7 @@ frappe.ui.form.on("Bank Transaction", {
"Journal Entry",
"Sales Invoice",
"Purchase Invoice",
+ "Bank Transaction",
];
}
});
@@ -49,7 +55,7 @@ const update_clearance_date = (frm, cdt, cdn) => {
frappe
.xcall(
"erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment",
- { doctype: cdt, docname: cdn }
+ { doctype: cdt, docname: cdn, bt_name: frm.doc.name }
)
.then((e) => {
if (e == "success") {
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
index 2bdaa1049b..768d2f0fa4 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
@@ -20,9 +20,11 @@
"currency",
"section_break_10",
"description",
- "section_break_14",
"reference_number",
+ "column_break_10",
"transaction_id",
+ "transaction_type",
+ "section_break_14",
"payment_entries",
"section_break_18",
"allocated_amount",
@@ -190,11 +192,21 @@
"label": "Withdrawal",
"oldfieldname": "credit",
"options": "currency"
+ },
+ {
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "transaction_type",
+ "fieldtype": "Data",
+ "label": "Transaction Type",
+ "length": 50
}
],
"is_submittable": 1,
"links": [],
- "modified": "2022-03-21 19:05:04.208222",
+ "modified": "2022-05-29 18:36:50.475964",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Transaction",
@@ -248,4 +260,4 @@
"states": [],
"title_field": "bank_account",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index a788514335..15162376c1 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -1,9 +1,6 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
-from functools import reduce
-
import frappe
from frappe.utils import flt
@@ -18,72 +15,137 @@ class BankTransaction(StatusUpdater):
self.clear_linked_payment_entries()
self.set_status()
+ _saving_flag = False
+
+ # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting
def on_update_after_submit(self):
- self.update_allocations()
- self.clear_linked_payment_entries()
- self.set_status(update=True)
+ "Run on save(). Avoid recursion caused by multiple saves"
+ if not self._saving_flag:
+ self._saving_flag = True
+ self.clear_linked_payment_entries()
+ self.update_allocations()
+ self._saving_flag = False
def on_cancel(self):
self.clear_linked_payment_entries(for_cancel=True)
self.set_status(update=True)
def update_allocations(self):
+ "The doctype does not allow modifications after submission, so write to the db direct"
if self.payment_entries:
- allocated_amount = reduce(
- lambda x, y: flt(x) + flt(y), [x.allocated_amount for x in self.payment_entries]
- )
+ allocated_amount = sum(p.allocated_amount for p in self.payment_entries)
else:
- allocated_amount = 0
+ allocated_amount = 0.0
- if allocated_amount:
- frappe.db.set_value(self.doctype, self.name, "allocated_amount", flt(allocated_amount))
- frappe.db.set_value(
- self.doctype,
- self.name,
- "unallocated_amount",
- abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount),
- )
+ amount = abs(flt(self.withdrawal) - flt(self.deposit))
+ self.db_set("allocated_amount", flt(allocated_amount))
+ self.db_set("unallocated_amount", amount - flt(allocated_amount))
+ self.reload()
+ self.set_status(update=True)
- else:
- frappe.db.set_value(self.doctype, self.name, "allocated_amount", 0)
- frappe.db.set_value(
- self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit))
- )
+ def add_payment_entries(self, vouchers):
+ "Add the vouchers with zero allocation. Save() will perform the allocations and clearance"
+ if 0.0 >= self.unallocated_amount:
+ frappe.throw(frappe._(f"Bank Transaction {self.name} is already fully reconciled"))
- amount = self.deposit or self.withdrawal
- if amount == self.allocated_amount:
- frappe.db.set_value(self.doctype, self.name, "status", "Reconciled")
+ added = False
+ for voucher in vouchers:
+ # Can't add same voucher twice
+ found = False
+ for pe in self.payment_entries:
+ if (
+ pe.payment_document == voucher["payment_doctype"]
+ and pe.payment_entry == voucher["payment_name"]
+ ):
+ found = True
+
+ if not found:
+ pe = {
+ "payment_document": voucher["payment_doctype"],
+ "payment_entry": voucher["payment_name"],
+ "allocated_amount": 0.0, # Temporary
+ }
+ child = self.append("payment_entries", pe)
+ added = True
+
+ # runs on_update_after_submit
+ if added:
+ self.save()
+
+ def allocate_payment_entries(self):
+ """Refactored from bank reconciliation tool.
+ Non-zero allocations must be amended/cleared manually
+ Get the bank transaction amount (b) and remove as we allocate
+ For each payment_entry if allocated_amount == 0:
+ - get the amount already allocated against all transactions (t), need latest date
+ - get the voucher amount (from gl) (v)
+ - allocate (a = v - t)
+ - a = 0: should already be cleared, so clear & remove payment_entry
+ - 0 < a <= u: allocate a & clear
+ - 0 < a, a > u: allocate u
+ - 0 > a: Error: already over-allocated
+ - clear means: set the latest transaction date as clearance date
+ """
+ gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account")
+ remaining_amount = self.unallocated_amount
+ for payment_entry in self.payment_entries:
+ if payment_entry.allocated_amount == 0.0:
+ unallocated_amount, should_clear, latest_transaction = get_clearance_details(
+ self, payment_entry
+ )
+
+ if 0.0 == unallocated_amount:
+ if should_clear:
+ latest_transaction.clear_linked_payment_entry(payment_entry)
+ self.db_delete_payment_entry(payment_entry)
+
+ elif remaining_amount <= 0.0:
+ self.db_delete_payment_entry(payment_entry)
+
+ elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount:
+ payment_entry.db_set("allocated_amount", unallocated_amount)
+ remaining_amount -= unallocated_amount
+ if should_clear:
+ latest_transaction.clear_linked_payment_entry(payment_entry)
+
+ elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount:
+ payment_entry.db_set("allocated_amount", remaining_amount)
+ remaining_amount = 0.0
+
+ elif 0.0 > unallocated_amount:
+ self.db_delete_payment_entry(payment_entry)
+ frappe.throw(
+ frappe._(f"Voucher {payment_entry.payment_entry} is over-allocated by {unallocated_amount}")
+ )
self.reload()
- def clear_linked_payment_entries(self, for_cancel=False):
+ def db_delete_payment_entry(self, payment_entry):
+ frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name})
+
+ @frappe.whitelist()
+ def remove_payment_entries(self):
for payment_entry in self.payment_entries:
- if payment_entry.payment_document == "Sales Invoice":
- self.clear_sales_invoice(payment_entry, for_cancel=for_cancel)
- elif payment_entry.payment_document in get_doctypes_for_bank_reconciliation():
- self.clear_simple_entry(payment_entry, for_cancel=for_cancel)
+ self.remove_payment_entry(payment_entry)
+ # runs on_update_after_submit
+ self.save()
- def clear_simple_entry(self, payment_entry, for_cancel=False):
- if payment_entry.payment_document == "Payment Entry":
- if (
- frappe.db.get_value("Payment Entry", payment_entry.payment_entry, "payment_type")
- == "Internal Transfer"
- ):
- if len(get_reconciled_bank_transactions(payment_entry)) < 2:
- return
+ def remove_payment_entry(self, payment_entry):
+ "Clear payment entry and clearance"
+ self.clear_linked_payment_entry(payment_entry, for_cancel=True)
+ self.remove(payment_entry)
- clearance_date = self.date if not for_cancel else None
- frappe.db.set_value(
- payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", clearance_date
- )
+ def clear_linked_payment_entries(self, for_cancel=False):
+ if for_cancel:
+ for payment_entry in self.payment_entries:
+ self.clear_linked_payment_entry(payment_entry, for_cancel)
+ else:
+ self.allocate_payment_entries()
- def clear_sales_invoice(self, payment_entry, for_cancel=False):
- clearance_date = self.date if not for_cancel else None
- frappe.db.set_value(
- "Sales Invoice Payment",
- dict(parenttype=payment_entry.payment_document, parent=payment_entry.payment_entry),
- "clearance_date",
- clearance_date,
+ def clear_linked_payment_entry(self, payment_entry, for_cancel=False):
+ clearance_date = None if for_cancel else self.date
+ set_voucher_clearance(
+ payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self
)
@@ -93,38 +155,112 @@ def get_doctypes_for_bank_reconciliation():
return frappe.get_hooks("bank_reconciliation_doctypes")
-def get_reconciled_bank_transactions(payment_entry):
- reconciled_bank_transactions = frappe.get_all(
- "Bank Transaction Payments",
- filters={"payment_entry": payment_entry.payment_entry},
- fields=["parent"],
+def get_clearance_details(transaction, payment_entry):
+ """
+ There should only be one bank gle for a voucher.
+ Could be none for a Bank Transaction.
+ But if a JE, could affect two banks.
+ Should only clear the voucher if all bank gles are allocated.
+ """
+ gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
+ gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry)
+ bt_allocations = get_total_allocated_amount(
+ payment_entry.payment_document, payment_entry.payment_entry
)
- return reconciled_bank_transactions
+ unallocated_amount = min(
+ transaction.unallocated_amount,
+ get_paid_amount(payment_entry, transaction.currency, gl_bank_account),
+ )
+ unmatched_gles = len(gles)
+ latest_transaction = transaction
+ for gle in gles:
+ if gle["gl_account"] == gl_bank_account:
+ if gle["amount"] <= 0.0:
+ frappe.throw(
+ frappe._(f"Voucher {payment_entry.payment_entry} value is broken: {gle['amount']}")
+ )
+
+ unmatched_gles -= 1
+ unallocated_amount = gle["amount"]
+ for a in bt_allocations:
+ if a["gl_account"] == gle["gl_account"]:
+ unallocated_amount = gle["amount"] - a["total"]
+ if frappe.utils.getdate(transaction.date) < a["latest_date"]:
+ latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"])
+ else:
+ # Must be a Journal Entry affecting more than one bank
+ for a in bt_allocations:
+ if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]:
+ unmatched_gles -= 1
+
+ return unallocated_amount, unmatched_gles == 0, latest_transaction
-def get_total_allocated_amount(payment_entry):
- return frappe.db.sql(
+def get_related_bank_gl_entries(doctype, docname):
+ # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
+ result = frappe.db.sql(
"""
SELECT
- SUM(btp.allocated_amount) as allocated_amount,
- bt.name
+ ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount,
+ gle.account AS gl_account
FROM
- `tabBank Transaction Payments` as btp
+ `tabGL Entry` gle
LEFT JOIN
- `tabBank Transaction` bt ON bt.name=btp.parent
+ `tabAccount` ac ON ac.name=gle.account
WHERE
- btp.payment_document = %s
- AND
- btp.payment_entry = %s
- AND
- bt.docstatus = 1""",
- (payment_entry.payment_document, payment_entry.payment_entry),
+ ac.account_type = 'Bank'
+ AND gle.voucher_type = %(doctype)s
+ AND gle.voucher_no = %(docname)s
+ AND is_cancelled = 0
+ """,
+ dict(doctype=doctype, docname=docname),
as_dict=True,
)
+ return result
-def get_paid_amount(payment_entry, currency, bank_account):
+def get_total_allocated_amount(doctype, docname):
+ """
+ Gets the sum of allocations for a voucher on each bank GL account
+ along with the latest bank transaction name & date
+ NOTE: query may also include just saved vouchers/payments but with zero allocated_amount
+ """
+ # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
+ result = frappe.db.sql(
+ """
+ SELECT total, latest_name, latest_date, gl_account FROM (
+ SELECT
+ ROW_NUMBER() OVER w AS rownum,
+ SUM(btp.allocated_amount) OVER(PARTITION BY ba.account) AS total,
+ FIRST_VALUE(bt.name) OVER w AS latest_name,
+ FIRST_VALUE(bt.date) OVER w AS latest_date,
+ ba.account AS gl_account
+ FROM
+ `tabBank Transaction Payments` btp
+ LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent
+ LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account
+ WHERE
+ btp.payment_document = %(doctype)s
+ AND btp.payment_entry = %(docname)s
+ AND bt.docstatus = 1
+ WINDOW w AS (PARTITION BY ba.account ORDER BY bt.date desc)
+ ) temp
+ WHERE
+ rownum = 1
+ """,
+ dict(doctype=doctype, docname=docname),
+ as_dict=True,
+ )
+ for row in result:
+ # Why is this *sometimes* a byte string?
+ if isinstance(row["latest_name"], bytes):
+ row["latest_name"] = row["latest_name"].decode()
+ row["latest_date"] = frappe.utils.getdate(row["latest_date"])
+ return result
+
+
+def get_paid_amount(payment_entry, currency, gl_bank_account):
if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
paid_amount_field = "paid_amount"
@@ -137,7 +273,7 @@ def get_paid_amount(payment_entry, currency, bank_account):
)
elif doc.payment_type == "Pay":
paid_amount_field = (
- "paid_amount" if doc.paid_to_account_currency == currency else "base_paid_amount"
+ "paid_amount" if doc.paid_from_account_currency == currency else "base_paid_amount"
)
return frappe.db.get_value(
@@ -147,7 +283,7 @@ def get_paid_amount(payment_entry, currency, bank_account):
elif payment_entry.payment_document == "Journal Entry":
return frappe.db.get_value(
"Journal Entry Account",
- {"parent": payment_entry.payment_entry, "account": bank_account},
+ {"parent": payment_entry.payment_entry, "account": gl_bank_account},
"sum(credit_in_account_currency)",
)
@@ -166,6 +302,12 @@ def get_paid_amount(payment_entry, currency, bank_account):
payment_entry.payment_document, payment_entry.payment_entry, "amount_paid"
)
+ elif payment_entry.payment_document == "Bank Transaction":
+ dep, wth = frappe.db.get_value(
+ "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal")
+ )
+ return abs(flt(wth) - flt(dep))
+
else:
frappe.throw(
"Please reconcile {0}: {1} manually".format(
@@ -174,18 +316,55 @@ def get_paid_amount(payment_entry, currency, bank_account):
)
-@frappe.whitelist()
-def unclear_reference_payment(doctype, docname):
- if frappe.db.exists(doctype, docname):
- doc = frappe.get_doc(doctype, docname)
- if doctype == "Sales Invoice":
- frappe.db.set_value(
- "Sales Invoice Payment",
- dict(parenttype=doc.payment_document, parent=doc.payment_entry),
- "clearance_date",
- None,
- )
- else:
- frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None)
+def set_voucher_clearance(doctype, docname, clearance_date, self):
+ if doctype in [
+ "Payment Entry",
+ "Journal Entry",
+ "Purchase Invoice",
+ "Expense Claim",
+ "Loan Repayment",
+ "Loan Disbursement",
+ ]:
+ if (
+ doctype == "Payment Entry"
+ and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer"
+ and len(get_reconciled_bank_transactions(doctype, docname)) < 2
+ ):
+ return
+ frappe.db.set_value(doctype, docname, "clearance_date", clearance_date)
- return doc.payment_entry
+ elif doctype == "Sales Invoice":
+ frappe.db.set_value(
+ "Sales Invoice Payment",
+ dict(parenttype=doctype, parent=docname),
+ "clearance_date",
+ clearance_date,
+ )
+
+ elif doctype == "Bank Transaction":
+ # For when a second bank transaction has fixed another, e.g. refund
+ bt = frappe.get_doc(doctype, docname)
+ if clearance_date:
+ vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}]
+ bt.add_payment_entries(vouchers)
+ else:
+ for pe in bt.payment_entries:
+ if pe.payment_document == self.doctype and pe.payment_entry == self.name:
+ bt.remove(pe)
+ bt.save()
+ break
+
+
+def get_reconciled_bank_transactions(doctype, docname):
+ return frappe.get_all(
+ "Bank Transaction Payments",
+ filters={"payment_document": doctype, "payment_entry": docname},
+ pluck="parent",
+ )
+
+
+@frappe.whitelist()
+def unclear_reference_payment(doctype, docname, bt_name):
+ bt = frappe.get_doc("Bank Transaction", bt_name)
+ set_voucher_clearance(doctype, docname, None, bt)
+ return docname
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
index 372c53d499..efb9d8c5ba 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
@@ -74,7 +74,7 @@ def get_header_mapping(columns, bank_account):
def get_bank_mapping(bank_account):
- bank_name = frappe.db.get_value("Bank Account", bank_account, "bank")
+ bank_name = frappe.get_cached_value("Bank Account", bank_account, "bank")
bank = frappe.get_doc("Bank", bank_name)
mapping = {row.file_field: row.bank_transaction_field for row in bank.bank_transaction_mapping}
diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index a5d0413799..f900e0775c 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -5,6 +5,7 @@ import json
import unittest
import frappe
+from frappe import utils
from frappe.tests.utils import FrappeTestCase
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
@@ -40,7 +41,12 @@ class TestBankTransaction(FrappeTestCase):
"Bank Transaction",
dict(description="Re 95282925234 FE/000002917 AT171513000281183046 Conrad Electronic"),
)
- linked_payments = get_linked_payments(bank_transaction.name, ["payment_entry", "exact_match"])
+ linked_payments = get_linked_payments(
+ bank_transaction.name,
+ ["payment_entry", "exact_match"],
+ from_date=bank_transaction.date,
+ to_date=utils.today(),
+ )
self.assertTrue(linked_payments[0][6] == "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
@@ -81,7 +87,12 @@ class TestBankTransaction(FrappeTestCase):
"Bank Transaction",
dict(description="Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07"),
)
- linked_payments = get_linked_payments(bank_transaction.name, ["payment_entry", "exact_match"])
+ linked_payments = get_linked_payments(
+ bank_transaction.name,
+ ["payment_entry", "exact_match"],
+ from_date=bank_transaction.date,
+ to_date=utils.today(),
+ )
self.assertTrue(linked_payments[0][3])
# Check error if already reconciled
diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json
index fc4dd200ea..f0566f4436 100644
--- a/erpnext/accounts/doctype/budget/budget.json
+++ b/erpnext/accounts/doctype/budget/budget.json
@@ -1,6 +1,7 @@
{
"actions": [],
"allow_import": 1,
+ "autoname": "naming_series:",
"creation": "2016-05-16 11:42:29.632528",
"doctype": "DocType",
"editable_grid": 1,
@@ -9,6 +10,7 @@
"budget_against",
"company",
"cost_center",
+ "naming_series",
"project",
"fiscal_year",
"column_break_3",
@@ -190,15 +192,26 @@
"label": "Budget Accounts",
"options": "Budget Account",
"reqd": 1
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Series",
+ "no_copy": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "set_only_once": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2020-10-06 15:13:54.055854",
+ "modified": "2022-10-10 22:14:36.361509",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -220,5 +233,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py
index 5527f9fb99..4c628a4d95 100644
--- a/erpnext/accounts/doctype/budget/budget.py
+++ b/erpnext/accounts/doctype/budget/budget.py
@@ -5,7 +5,6 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.model.naming import make_autoname
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -23,11 +22,6 @@ class DuplicateBudgetError(frappe.ValidationError):
class Budget(Document):
- def autoname(self):
- self.name = make_autoname(
- self.get(frappe.scrub(self.budget_against)) + "/" + self.fiscal_year + "/.###"
- )
-
def validate(self):
if not self.get(frappe.scrub(self.budget_against)):
frappe.throw(_("{0} is mandatory").format(self.budget_against))
@@ -65,7 +59,7 @@ class Budget(Document):
account_list = []
for d in self.get("accounts"):
if d.account:
- account_details = frappe.db.get_value(
+ account_details = frappe.get_cached_value(
"Account", d.account, ["is_group", "company", "report_type"], as_dict=1
)
@@ -109,8 +103,11 @@ class Budget(Document):
):
self.applicable_on_booking_actual_expenses = 1
+ def before_naming(self):
+ self.naming_series = f"{{{frappe.scrub(self.budget_against)}}}./.{self.fiscal_year}/.###"
-def validate_expense_against_budget(args):
+
+def validate_expense_against_budget(args, expense_amount=0):
args = frappe._dict(args)
if args.get("company") and not args.fiscal_year:
@@ -178,15 +175,20 @@ def validate_expense_against_budget(args):
) # nosec
if budget_records:
- validate_budget_records(args, budget_records)
+ validate_budget_records(args, budget_records, expense_amount)
-def validate_budget_records(args, budget_records):
+def validate_budget_records(args, budget_records, expense_amount):
for budget in budget_records:
if flt(budget.budget_amount):
- amount = get_amount(args, budget)
+ amount = expense_amount or get_amount(args, budget)
yearly_action, monthly_action = get_actions(args, budget)
+ if yearly_action in ("Stop", "Warn"):
+ compare_expense_with_budget(
+ args, flt(budget.budget_amount), _("Annual"), yearly_action, budget.budget_against, amount
+ )
+
if monthly_action in ["Stop", "Warn"]:
budget_amount = get_accumulated_monthly_budget(
budget.monthly_distribution, args.posting_date, args.fiscal_year, budget.budget_amount
@@ -198,28 +200,28 @@ def validate_budget_records(args, budget_records):
args, budget_amount, _("Accumulated Monthly"), monthly_action, budget.budget_against, amount
)
- if (
- yearly_action in ("Stop", "Warn")
- and monthly_action != "Stop"
- and yearly_action != monthly_action
- ):
- compare_expense_with_budget(
- args, flt(budget.budget_amount), _("Annual"), yearly_action, budget.budget_against, amount
- )
-
def compare_expense_with_budget(args, budget_amount, action_for, action, budget_against, amount=0):
- actual_expense = amount or get_actual_expense(args)
- if actual_expense > budget_amount:
- diff = actual_expense - budget_amount
+ actual_expense = get_actual_expense(args)
+ total_expense = actual_expense + amount
+
+ if total_expense > budget_amount:
+ if actual_expense > budget_amount:
+ error_tense = _("is already")
+ diff = actual_expense - budget_amount
+ else:
+ error_tense = _("will be")
+ diff = total_expense - budget_amount
+
currency = frappe.get_cached_value("Company", args.company, "default_currency")
- msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It will exceed by {5}").format(
+ msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It {5} exceed by {6}").format(
_(action_for),
frappe.bold(args.account),
- args.budget_against_field,
+ frappe.unscrub(args.budget_against_field),
frappe.bold(budget_against),
frappe.bold(fmt_money(budget_amount, currency=currency)),
+ error_tense,
frappe.bold(fmt_money(diff, currency=currency)),
)
@@ -230,9 +232,9 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_
action = "Warn"
if action == "Stop":
- frappe.throw(msg, BudgetError)
+ frappe.throw(msg, BudgetError, title=_("Budget Exceeded"))
else:
- frappe.msgprint(msg, indicator="orange")
+ frappe.msgprint(msg, indicator="orange", title=_("Budget Exceeded"))
def get_actions(args, budget):
@@ -309,7 +311,7 @@ def get_other_condition(args, budget, for_doc):
if args.get("fiscal_year"):
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
- start_date, end_date = frappe.db.get_value(
+ start_date, end_date = frappe.get_cached_value(
"Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
)
@@ -354,7 +356,9 @@ def get_actual_expense(args):
"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
- where gle.account=%(account)s
+ where
+ is_cancelled = 0
+ and gle.account=%(account)s
{condition1}
and gle.fiscal_year=%(fiscal_year)s
and gle.company=%(company)s
@@ -382,7 +386,7 @@ def get_accumulated_monthly_budget(monthly_distribution, posting_date, fiscal_ye
):
distribution.setdefault(d.month, d.percentage_allocation)
- dt = frappe.db.get_value("Fiscal Year", fiscal_year, "year_start_date")
+ dt = frappe.get_cached_value("Fiscal Year", fiscal_year, "year_start_date")
accumulated_percentage = 0.0
while dt <= getdate(posting_date):
diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py
index c48c7d97a2..11af9a29f6 100644
--- a/erpnext/accounts/doctype/budget/test_budget.py
+++ b/erpnext/accounts/doctype/budget/test_budget.py
@@ -334,6 +334,39 @@ class TestBudget(unittest.TestCase):
budget.cancel()
jv.cancel()
+ def test_monthly_budget_against_main_cost_center(self):
+ from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
+ from erpnext.accounts.doctype.cost_center_allocation.test_cost_center_allocation import (
+ create_cost_center_allocation,
+ )
+
+ cost_centers = [
+ "Main Budget Cost Center 1",
+ "Sub Budget Cost Center 1",
+ "Sub Budget Cost Center 2",
+ ]
+
+ for cc in cost_centers:
+ create_cost_center(cost_center_name=cc, company="_Test Company")
+
+ create_cost_center_allocation(
+ "_Test Company",
+ "Main Budget Cost Center 1 - _TC",
+ {"Sub Budget Cost Center 1 - _TC": 60, "Sub Budget Cost Center 2 - _TC": 40},
+ )
+
+ make_budget(budget_against="Cost Center", cost_center="Main Budget Cost Center 1 - _TC")
+
+ jv = make_journal_entry(
+ "_Test Account Cost for Goods Sold - _TC",
+ "_Test Bank - _TC",
+ 400000,
+ "Main Budget Cost Center 1 - _TC",
+ posting_date=nowdate(),
+ )
+
+ self.assertRaises(BudgetError, jv.submit)
+
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":
diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
index 01bf1c23e9..220b74727b 100644
--- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
+++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
@@ -45,14 +45,14 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company):
- parent_company, allow_account_creation_against_child_company = frappe.db.get_value(
- "Company", {"name": company}, ["parent_company", "allow_account_creation_against_child_company"]
+ parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
+ "Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
if parent_company and (not allow_account_creation_against_child_company):
msg = _("{} is a child company.").format(frappe.bold(company)) + " "
msg += _("Please import accounts against parent company or enable {} in company master.").format(
- frappe.bold("Allow Account Creation Against Child Company")
+ frappe.bold(_("Allow Account Creation Against Child Company"))
)
frappe.throw(msg, title=_("Wrong Company"))
@@ -485,6 +485,10 @@ def set_default_accounts(company):
"default_payable_account": frappe.db.get_value(
"Account", {"company": company.name, "account_type": "Payable", "is_group": 0}
),
+ "default_provisional_account": frappe.db.get_value(
+ "Account",
+ {"company": company.name, "account_type": "Service Received But Not Billed", "is_group": 0},
+ ),
}
)
diff --git a/erpnext/accounts/doctype/cost_center/cost_center.py b/erpnext/accounts/doctype/cost_center/cost_center.py
index 31055c3fb4..e8b34bbf03 100644
--- a/erpnext/accounts/doctype/cost_center/cost_center.py
+++ b/erpnext/accounts/doctype/cost_center/cost_center.py
@@ -16,7 +16,7 @@ class CostCenter(NestedSet):
from erpnext.accounts.utils import get_autoname_with_number
self.name = get_autoname_with_number(
- self.cost_center_number, self.cost_center_name, None, self.company
+ self.cost_center_number, self.cost_center_name, self.company
)
def validate(self):
diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py
index d25016fe59..54ffe21a15 100644
--- a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py
+++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py
@@ -28,9 +28,14 @@ class InvalidDateError(frappe.ValidationError):
class CostCenterAllocation(Document):
+ def __init__(self, *args, **kwargs):
+ super(CostCenterAllocation, self).__init__(*args, **kwargs)
+ self._skip_from_date_validation = False
+
def validate(self):
self.validate_total_allocation_percentage()
- self.validate_from_date_based_on_existing_gle()
+ if not self._skip_from_date_validation:
+ self.validate_from_date_based_on_existing_gle()
self.validate_backdated_allocation()
self.validate_main_cost_center()
self.validate_child_cost_centers()
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
index 7921fcc2b9..c62b711f2c 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
@@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"api_details_section",
+ "disabled",
"service_provider",
"api_endpoint",
"url",
@@ -77,12 +78,18 @@
"label": "Service Provider",
"options": "frankfurter.app\nexchangerate.host\nCustom",
"reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-01-10 15:51:14.521174",
+ "modified": "2023-01-09 12:19:03.955906",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py
index 9874d66fa5..b4df0a5270 100644
--- a/erpnext/accounts/doctype/dunning/dunning.py
+++ b/erpnext/accounts/doctype/dunning/dunning.py
@@ -19,7 +19,7 @@ class Dunning(AccountsController):
self.validate_overdue_days()
self.validate_amount()
if not self.income_account:
- self.income_account = frappe.db.get_value("Company", self.company, "default_income_account")
+ self.income_account = frappe.get_cached_value("Company", self.company, "default_income_account")
def validate_overdue_days(self):
self.overdue_days = (getdate(self.posting_date) - getdate(self.due_date)).days or 0
@@ -40,7 +40,7 @@ class Dunning(AccountsController):
def on_cancel(self):
if self.dunning_amount:
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
def make_gl_entries(self):
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js
index 926a442f80..f72ecc9e50 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js
@@ -26,7 +26,7 @@ frappe.ui.form.on('Exchange Rate Revaluation', {
doc: frm.doc,
callback: function(r) {
if (r.message) {
- frm.add_custom_button(__('Journal Entry'), function() {
+ frm.add_custom_button(__('Journal Entries'), function() {
return frm.events.make_jv(frm);
}, __('Create'));
}
@@ -35,10 +35,11 @@ frappe.ui.form.on('Exchange Rate Revaluation', {
}
},
- get_entries: function(frm) {
+ get_entries: function(frm, account) {
frappe.call({
method: "get_accounts_data",
doc: cur_frm.doc,
+ account: account,
callback: function(r){
frappe.model.clear_table(frm.doc, "accounts");
if(r.message) {
@@ -57,7 +58,6 @@ frappe.ui.form.on('Exchange Rate Revaluation', {
let total_gain_loss = 0;
frm.doc.accounts.forEach((d) => {
- d.gain_loss = flt(d.new_balance_in_base_currency, precision("new_balance_in_base_currency", d)) - flt(d.balance_in_base_currency, precision("balance_in_base_currency", d));
total_gain_loss += flt(d.gain_loss, precision("gain_loss", d));
});
@@ -66,13 +66,19 @@ frappe.ui.form.on('Exchange Rate Revaluation', {
},
make_jv : function(frm) {
+ let revaluation_journal = null;
+ let zero_balance_journal = null;
frappe.call({
- method: "make_jv_entry",
+ method: "make_jv_entries",
doc: frm.doc,
+ freeze: true,
+ freeze_message: "Making Journal Entries...",
callback: function(r){
if (r.message) {
- var doc = frappe.model.sync(r.message)[0];
- frappe.set_route("Form", doc.doctype, doc.name);
+ let response = r.message;
+ if(response['revaluation_jv'] || response['zero_balance_jv']) {
+ frappe.msgprint(__("Journals have been created"));
+ }
}
}
});
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json
index a7315a672a..0d198ca120 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json
@@ -1,389 +1,160 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 1,
- "allow_rename": 0,
- "autoname": "ACC-ERR-.YYYY.-.#####",
- "beta": 0,
- "creation": "2018-04-13 18:25:55.943587",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "allow_import": 1,
+ "autoname": "ACC-ERR-.YYYY.-.#####",
+ "creation": "2018-04-13 18:25:55.943587",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "posting_date",
+ "column_break_2",
+ "company",
+ "section_break_4",
+ "get_entries",
+ "accounts",
+ "section_break_6",
+ "gain_loss_unbooked",
+ "gain_loss_booked",
+ "column_break_10",
+ "total_gain_loss",
+ "amended_from"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": "Today",
- "fieldname": "posting_date",
- "fieldtype": "Date",
- "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": "Posting Date",
- "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
- },
+ "default": "Today",
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Posting Date",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_2",
- "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
- },
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
{
- "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": 1,
- "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": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_4",
- "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,
- "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
- },
+ "fieldname": "section_break_4",
+ "fieldtype": "Section Break"
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "get_entries",
- "fieldtype": "Button",
- "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": "Get Entries",
- "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
- },
+ "fieldname": "get_entries",
+ "fieldtype": "Button",
+ "label": "Get Entries"
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "accounts",
- "fieldtype": "Table",
- "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": "Exchange Rate Revaluation Account",
- "length": 0,
- "no_copy": 1,
- "options": "Exchange Rate Revaluation 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": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
+ "fieldname": "accounts",
+ "fieldtype": "Table",
+ "label": "Exchange Rate Revaluation Account",
+ "no_copy": 1,
+ "options": "Exchange Rate Revaluation Account",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_6",
- "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,
- "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
- },
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break"
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "total_gain_loss",
- "fieldtype": "Currency",
- "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": "Total Gain/Loss",
- "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": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
- },
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Exchange Rate Revaluation",
+ "print_hide": 1,
+ "read_only": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "amended_from",
- "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": "Amended From",
- "length": 0,
- "no_copy": 1,
- "options": "Exchange Rate Revaluation",
- "permlevel": 0,
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "fieldname": "gain_loss_unbooked",
+ "fieldtype": "Currency",
+ "label": "Gain/Loss from Revaluation",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "description": "Gain/Loss accumulated in foreign currency account. Accounts with '0' balance in either Base or Account currency",
+ "fieldname": "gain_loss_booked",
+ "fieldtype": "Currency",
+ "label": "Gain/Loss already booked",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "total_gain_loss",
+ "fieldtype": "Currency",
+ "label": "Total Gain/Loss",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 1,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2018-08-21 16:15:34.660715",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "Exchange Rate Revaluation",
- "name_case": "",
- "owner": "Administrator",
+ ],
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-12-29 19:38:24.416529",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Exchange Rate Revaluation",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 1,
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
"write": 1
- },
+ },
{
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Accounts Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 1,
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
"write": 1
- },
+ },
{
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Accounts User",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 1,
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "submit": 1,
"write": 1
}
- ],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
index 2f81c5fb75..d67d59b5d4 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
@@ -3,10 +3,12 @@
import frappe
-from frappe import _
+from frappe import _, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
-from frappe.utils import flt
+from frappe.query_builder import Criterion, Order
+from frappe.query_builder.functions import NullIf, Sum
+from frappe.utils import flt, get_link_to_form
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
@@ -19,11 +21,25 @@ class ExchangeRateRevaluation(Document):
def set_total_gain_loss(self):
total_gain_loss = 0
+
+ gain_loss_booked = 0
+ gain_loss_unbooked = 0
+
for d in self.accounts:
- d.gain_loss = flt(
- d.new_balance_in_base_currency, d.precision("new_balance_in_base_currency")
- ) - flt(d.balance_in_base_currency, d.precision("balance_in_base_currency"))
+ if not d.zero_balance:
+ d.gain_loss = flt(
+ d.new_balance_in_base_currency, d.precision("new_balance_in_base_currency")
+ ) - flt(d.balance_in_base_currency, d.precision("balance_in_base_currency"))
+
+ if d.zero_balance:
+ gain_loss_booked += flt(d.gain_loss, d.precision("gain_loss"))
+ else:
+ gain_loss_unbooked += flt(d.gain_loss, d.precision("gain_loss"))
+
total_gain_loss += flt(d.gain_loss, d.precision("gain_loss"))
+
+ self.gain_loss_booked = gain_loss_booked
+ self.gain_loss_unbooked = gain_loss_unbooked
self.total_gain_loss = flt(total_gain_loss, self.precision("total_gain_loss"))
def validate_mandatory(self):
@@ -35,98 +51,206 @@ class ExchangeRateRevaluation(Document):
@frappe.whitelist()
def check_journal_entry_condition(self):
- total_debit = frappe.db.get_value(
- "Journal Entry Account",
- {"reference_type": "Exchange Rate Revaluation", "reference_name": self.name, "docstatus": 1},
- "sum(debit) as sum",
+ exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
+
+ jea = qb.DocType("Journal Entry Account")
+ journals = (
+ qb.from_(jea)
+ .select(jea.parent)
+ .distinct()
+ .where(
+ (jea.reference_type == "Exchange Rate Revaluation")
+ & (jea.reference_name == self.name)
+ & (jea.docstatus == 1)
+ )
+ .run()
)
- total_amt = 0
- for d in self.accounts:
- total_amt = total_amt + d.new_balance_in_base_currency
+ if journals:
+ gle = qb.DocType("GL Entry")
+ total_amt = (
+ qb.from_(gle)
+ .select((Sum(gle.credit) - Sum(gle.debit)).as_("total_amount"))
+ .where(
+ (gle.voucher_type == "Journal Entry")
+ & (gle.voucher_no.isin(journals))
+ & (gle.account == exchange_gain_loss_account)
+ & (gle.is_cancelled == 0)
+ )
+ .run()
+ )
- if total_amt != total_debit:
- return True
+ if total_amt and total_amt[0][0] != self.total_gain_loss:
+ return True
+ else:
+ return False
- return False
+ return True
@frappe.whitelist()
- def get_accounts_data(self, account=None):
- accounts = []
+ def get_accounts_data(self):
self.validate_mandatory()
- company_currency = erpnext.get_company_currency(self.company)
+ account_details = self.get_account_balance_from_gle(
+ company=self.company, posting_date=self.posting_date, account=None, party_type=None, party=None
+ )
+ accounts_with_new_balance = self.calculate_new_account_balance(
+ self.company, self.posting_date, account_details
+ )
+
+ if not accounts_with_new_balance:
+ self.throw_invalid_response_message(account_details)
+
+ return accounts_with_new_balance
+
+ @staticmethod
+ def get_account_balance_from_gle(company, posting_date, account, party_type, party):
+ account_details = []
+
+ if company and posting_date:
+ company_currency = erpnext.get_company_currency(company)
+
+ acc = qb.DocType("Account")
+ if account:
+ accounts = [account]
+ else:
+ res = (
+ qb.from_(acc)
+ .select(acc.name)
+ .where(
+ (acc.is_group == 0)
+ & (acc.report_type == "Balance Sheet")
+ & (acc.root_type.isin(["Asset", "Liability", "Equity"]))
+ & (acc.account_type != "Stock")
+ & (acc.company == company)
+ & (acc.account_currency != company_currency)
+ )
+ .orderby(acc.name)
+ .run(as_list=True)
+ )
+ accounts = [x[0] for x in res]
+
+ if accounts:
+ having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & (
+ (qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0)
+ )
+
+ gle = qb.DocType("GL Entry")
+
+ # conditions
+ conditions = []
+ conditions.append(gle.account.isin(accounts))
+ conditions.append(gle.posting_date.lte(posting_date))
+ conditions.append(gle.is_cancelled == 0)
+
+ if party_type:
+ conditions.append(gle.party_type == party_type)
+ if party:
+ conditions.append(gle.party == party)
+
+ account_details = (
+ qb.from_(gle)
+ .select(
+ gle.account,
+ gle.party_type,
+ gle.party,
+ gle.account_currency,
+ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_(
+ "balance_in_account_currency"
+ ),
+ (Sum(gle.debit) - Sum(gle.credit)).as_("balance"),
+ (Sum(gle.debit) - Sum(gle.credit) == 0)
+ ^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_(
+ "zero_balance"
+ ),
+ )
+ .where(Criterion.all(conditions))
+ .groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))
+ .having(having_clause)
+ .orderby(gle.account)
+ .run(as_dict=True)
+ )
+
+ return account_details
+
+ @staticmethod
+ def calculate_new_account_balance(company, posting_date, account_details):
+ accounts = []
+ company_currency = erpnext.get_company_currency(company)
precision = get_field_precision(
frappe.get_meta("Exchange Rate Revaluation Account").get_field("new_balance_in_base_currency"),
company_currency,
)
- account_details = self.get_accounts_from_gle()
- for d in account_details:
- current_exchange_rate = (
- d.balance / d.balance_in_account_currency if d.balance_in_account_currency else 0
- )
- new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, self.posting_date)
- new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate)
- gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
- if gain_loss:
- accounts.append(
- {
- "account": d.account,
- "party_type": d.party_type,
- "party": d.party,
- "account_currency": d.account_currency,
- "balance_in_base_currency": d.balance,
- "balance_in_account_currency": d.balance_in_account_currency,
- "current_exchange_rate": current_exchange_rate,
- "new_exchange_rate": new_exchange_rate,
- "new_balance_in_base_currency": new_balance_in_base_currency,
- }
+ if account_details:
+ # Handle Accounts with balance in both Account/Base Currency
+ for d in [x for x in account_details if not x.zero_balance]:
+ current_exchange_rate = (
+ d.balance / d.balance_in_account_currency if d.balance_in_account_currency else 0
)
+ new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, posting_date)
+ new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate)
+ gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
+ if gain_loss:
+ accounts.append(
+ {
+ "account": d.account,
+ "party_type": d.party_type,
+ "party": d.party,
+ "account_currency": d.account_currency,
+ "balance_in_base_currency": d.balance,
+ "balance_in_account_currency": d.balance_in_account_currency,
+ "zero_balance": d.zero_balance,
+ "current_exchange_rate": current_exchange_rate,
+ "new_exchange_rate": new_exchange_rate,
+ "new_balance_in_base_currency": new_balance_in_base_currency,
+ "new_balance_in_account_currency": d.balance_in_account_currency,
+ "gain_loss": gain_loss,
+ }
+ )
- if not accounts:
- self.throw_invalid_response_message(account_details)
+ # Handle Accounts with '0' balance in Account/Base Currency
+ for d in [x for x in account_details if x.zero_balance]:
+
+ # TODO: Set new balance in Base/Account currency
+ if d.balance > 0:
+ current_exchange_rate = new_exchange_rate = 0
+
+ new_balance_in_account_currency = 0 # this will be '0'
+ new_balance_in_base_currency = 0 # this will be '0'
+ gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
+ else:
+ new_exchange_rate = 0
+ new_balance_in_base_currency = 0
+ new_balance_in_account_currency = 0
+
+ current_exchange_rate = calculate_exchange_rate_using_last_gle(
+ company, d.account, d.party_type, d.party
+ )
+
+ gain_loss = new_balance_in_account_currency - (
+ current_exchange_rate * d.balance_in_account_currency
+ )
+
+ if gain_loss:
+ accounts.append(
+ {
+ "account": d.account,
+ "party_type": d.party_type,
+ "party": d.party,
+ "account_currency": d.account_currency,
+ "balance_in_base_currency": d.balance,
+ "balance_in_account_currency": d.balance_in_account_currency,
+ "zero_balance": d.zero_balance,
+ "current_exchange_rate": current_exchange_rate,
+ "new_exchange_rate": new_exchange_rate,
+ "new_balance_in_base_currency": new_balance_in_base_currency,
+ "new_balance_in_account_currency": new_balance_in_account_currency,
+ "gain_loss": gain_loss,
+ }
+ )
return accounts
- def get_accounts_from_gle(self):
- company_currency = erpnext.get_company_currency(self.company)
- accounts = frappe.db.sql_list(
- """
- select name
- from tabAccount
- where is_group = 0
- and report_type = 'Balance Sheet'
- and root_type in ('Asset', 'Liability', 'Equity')
- and account_type != 'Stock'
- and company=%s
- and account_currency != %s
- order by name""",
- (self.company, company_currency),
- )
-
- account_details = []
- if accounts:
- account_details = frappe.db.sql(
- """
- select
- account, party_type, party, account_currency,
- sum(debit_in_account_currency) - sum(credit_in_account_currency) as balance_in_account_currency,
- sum(debit) - sum(credit) as balance
- from `tabGL Entry`
- where account in (%s)
- and posting_date <= %s
- and is_cancelled = 0
- group by account, NULLIF(party_type,''), NULLIF(party,'')
- having sum(debit) != sum(credit)
- order by account
- """
- % (", ".join(["%s"] * len(accounts)), "%s"),
- tuple(accounts + [self.posting_date]),
- as_dict=1,
- )
-
- return account_details
-
def throw_invalid_response_message(self, account_details):
if account_details:
message = _("No outstanding invoices require exchange rate revaluation")
@@ -134,11 +258,7 @@ class ExchangeRateRevaluation(Document):
message = _("No outstanding invoices found")
frappe.msgprint(message)
- @frappe.whitelist()
- def make_jv_entry(self):
- if self.total_gain_loss == 0:
- return
-
+ def get_for_unrealized_gain_loss_account(self):
unrealized_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "unrealized_exchange_gain_loss_account"
)
@@ -146,6 +266,130 @@ class ExchangeRateRevaluation(Document):
frappe.throw(
_("Please set Unrealized Exchange Gain/Loss Account in Company {0}").format(self.company)
)
+ return unrealized_exchange_gain_loss_account
+
+ @frappe.whitelist()
+ def make_jv_entries(self):
+ zero_balance_jv = self.make_jv_for_zero_balance()
+ if zero_balance_jv:
+ frappe.msgprint(
+ f"Zero Balance Journal: {get_link_to_form('Journal Entry', zero_balance_jv.name)}"
+ )
+
+ revaluation_jv = self.make_jv_for_revaluation()
+ if revaluation_jv:
+ frappe.msgprint(
+ f"Revaluation Journal: {get_link_to_form('Journal Entry', revaluation_jv.name)}"
+ )
+
+ return {
+ "revaluation_jv": revaluation_jv.name if revaluation_jv else None,
+ "zero_balance_jv": zero_balance_jv.name if zero_balance_jv else None,
+ }
+
+ def make_jv_for_zero_balance(self):
+ if self.gain_loss_booked == 0:
+ return
+
+ accounts = [x for x in self.accounts if x.zero_balance]
+
+ if not accounts:
+ return
+
+ unrealized_exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
+
+ journal_entry = frappe.new_doc("Journal Entry")
+ journal_entry.voucher_type = "Exchange Gain Or Loss"
+ journal_entry.company = self.company
+ journal_entry.posting_date = self.posting_date
+ journal_entry.multi_currency = 1
+
+ journal_entry_accounts = []
+ for d in accounts:
+ journal_account = frappe._dict(
+ {
+ "account": d.get("account"),
+ "party_type": d.get("party_type"),
+ "party": d.get("party"),
+ "account_currency": d.get("account_currency"),
+ "balance": flt(
+ d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")
+ ),
+ "exchange_rate": 0,
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ "reference_type": "Exchange Rate Revaluation",
+ "reference_name": self.name,
+ }
+ )
+
+ # Account Currency has balance
+ if d.get("balance_in_account_currency") and not d.get("new_balance_in_account_currency"):
+ dr_or_cr = (
+ "credit_in_account_currency"
+ if d.get("balance_in_account_currency") > 0
+ else "debit_in_account_currency"
+ )
+ reverse_dr_or_cr = (
+ "debit_in_account_currency"
+ if dr_or_cr == "credit_in_account_currency"
+ else "credit_in_account_currency"
+ )
+ journal_account.update(
+ {
+ dr_or_cr: flt(
+ abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency")
+ ),
+ reverse_dr_or_cr: 0,
+ "debit": 0,
+ "credit": 0,
+ }
+ )
+ elif d.get("balance_in_base_currency") and not d.get("new_balance_in_base_currency"):
+ # Base currency has balance
+ dr_or_cr = "credit" if d.get("balance_in_base_currency") > 0 else "debit"
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+ journal_account.update(
+ {
+ dr_or_cr: flt(
+ abs(d.get("balance_in_base_currency")), d.precision("balance_in_base_currency")
+ ),
+ reverse_dr_or_cr: 0,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 0,
+ }
+ )
+
+ journal_entry_accounts.append(journal_account)
+
+ journal_entry_accounts.append(
+ {
+ "account": unrealized_exchange_gain_loss_account,
+ "balance": get_balance_on(unrealized_exchange_gain_loss_account),
+ "debit": abs(self.gain_loss_booked) if self.gain_loss_booked < 0 else 0,
+ "credit": abs(self.gain_loss_booked) if self.gain_loss_booked > 0 else 0,
+ "debit_in_account_currency": abs(self.gain_loss_booked) if self.gain_loss_booked < 0 else 0,
+ "credit_in_account_currency": self.gain_loss_booked if self.gain_loss_booked > 0 else 0,
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ "exchange_rate": 1,
+ "reference_type": "Exchange Rate Revaluation",
+ "reference_name": self.name,
+ }
+ )
+
+ journal_entry.set("accounts", journal_entry_accounts)
+ journal_entry.set_total_debit_credit()
+ journal_entry.save()
+ return journal_entry
+
+ def make_jv_for_revaluation(self):
+ if self.gain_loss_unbooked == 0:
+ return
+
+ accounts = [x for x in self.accounts if not x.zero_balance]
+ if not accounts:
+ return
+
+ unrealized_exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Rate Revaluation"
@@ -154,7 +398,7 @@ class ExchangeRateRevaluation(Document):
journal_entry.multi_currency = 1
journal_entry_accounts = []
- for d in self.accounts:
+ for d in accounts:
dr_or_cr = (
"debit_in_account_currency"
if d.get("balance_in_account_currency") > 0
@@ -179,6 +423,7 @@ class ExchangeRateRevaluation(Document):
dr_or_cr: flt(
abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency")
),
+ "cost_center": erpnext.get_default_cost_center(self.company),
"exchange_rate": flt(d.get("new_exchange_rate"), d.precision("new_exchange_rate")),
"reference_type": "Exchange Rate Revaluation",
"reference_name": self.name,
@@ -196,6 +441,7 @@ class ExchangeRateRevaluation(Document):
reverse_dr_or_cr: flt(
abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency")
),
+ "cost_center": erpnext.get_default_cost_center(self.company),
"exchange_rate": flt(d.get("current_exchange_rate"), d.precision("current_exchange_rate")),
"reference_type": "Exchange Rate Revaluation",
"reference_name": self.name,
@@ -206,8 +452,11 @@ class ExchangeRateRevaluation(Document):
{
"account": unrealized_exchange_gain_loss_account,
"balance": get_balance_on(unrealized_exchange_gain_loss_account),
- "debit_in_account_currency": abs(self.total_gain_loss) if self.total_gain_loss < 0 else 0,
- "credit_in_account_currency": self.total_gain_loss if self.total_gain_loss > 0 else 0,
+ "debit_in_account_currency": abs(self.gain_loss_unbooked)
+ if self.gain_loss_unbooked < 0
+ else 0,
+ "credit_in_account_currency": self.gain_loss_unbooked if self.gain_loss_unbooked > 0 else 0,
+ "cost_center": erpnext.get_default_cost_center(self.company),
"exchange_rate": 1,
"reference_type": "Exchange Rate Revaluation",
"reference_name": self.name,
@@ -217,38 +466,91 @@ class ExchangeRateRevaluation(Document):
journal_entry.set("accounts", journal_entry_accounts)
journal_entry.set_amounts_in_company_currency()
journal_entry.set_total_debit_credit()
- return journal_entry.as_dict()
+ journal_entry.save()
+ return journal_entry
+
+
+def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
+ """
+ Use last GL entry to calculate exchange rate
+ """
+ last_exchange_rate = None
+ if company and account:
+ gl = qb.DocType("GL Entry")
+
+ # build conditions
+ conditions = []
+ conditions.append(gl.company == company)
+ conditions.append(gl.account == account)
+ conditions.append(gl.is_cancelled == 0)
+ if party_type:
+ conditions.append(gl.party_type == party_type)
+ if party:
+ conditions.append(gl.party == party)
+
+ voucher_type, voucher_no = (
+ qb.from_(gl)
+ .select(gl.voucher_type, gl.voucher_no)
+ .where(Criterion.all(conditions))
+ .orderby(gl.posting_date, order=Order.desc)
+ .limit(1)
+ .run()[0]
+ )
+
+ last_exchange_rate = (
+ qb.from_(gl)
+ .select((gl.debit - gl.credit) / (gl.debit_in_account_currency - gl.credit_in_account_currency))
+ .where(
+ (gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
+ )
+ .orderby(gl.posting_date, order=Order.desc)
+ .limit(1)
+ .run()[0][0]
+ )
+
+ return last_exchange_rate
@frappe.whitelist()
-def get_account_details(account, company, posting_date, party_type=None, party=None):
- account_currency, account_type = frappe.db.get_value(
+def get_account_details(company, posting_date, account, party_type=None, party=None):
+ if not (company and posting_date):
+ frappe.throw(_("Company and Posting Date is mandatory"))
+
+ account_currency, account_type = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"]
)
+
if account_type in ["Receivable", "Payable"] and not (party_type and party):
frappe.throw(_("Party Type and Party is mandatory for {0} account").format(account_type))
account_details = {}
company_currency = erpnext.get_company_currency(company)
- balance = get_balance_on(
- account, date=posting_date, party_type=party_type, party=party, in_account_currency=False
+
+ account_details = {
+ "account_currency": account_currency,
+ }
+ account_balance = ExchangeRateRevaluation.get_account_balance_from_gle(
+ company=company, posting_date=posting_date, account=account, party_type=party_type, party=party
)
- if balance:
- balance_in_account_currency = get_balance_on(
- account, date=posting_date, party_type=party_type, party=party
+
+ if account_balance and (
+ account_balance[0].balance or account_balance[0].balance_in_account_currency
+ ):
+ account_with_new_balance = ExchangeRateRevaluation.calculate_new_account_balance(
+ company, posting_date, account_balance
)
- current_exchange_rate = (
- balance / balance_in_account_currency if balance_in_account_currency else 0
+ row = account_with_new_balance[0]
+ account_details.update(
+ {
+ "balance_in_base_currency": row["balance_in_base_currency"],
+ "balance_in_account_currency": row["balance_in_account_currency"],
+ "current_exchange_rate": row["current_exchange_rate"],
+ "new_exchange_rate": row["new_exchange_rate"],
+ "new_balance_in_base_currency": row["new_balance_in_base_currency"],
+ "new_balance_in_account_currency": row["new_balance_in_account_currency"],
+ "zero_balance": row["zero_balance"],
+ "gain_loss": row["gain_loss"],
+ }
)
- new_exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
- new_balance_in_base_currency = balance_in_account_currency * new_exchange_rate
- account_details = {
- "account_currency": account_currency,
- "balance_in_base_currency": balance,
- "balance_in_account_currency": balance_in_account_currency,
- "current_exchange_rate": current_exchange_rate,
- "new_exchange_rate": new_exchange_rate,
- "new_balance_in_base_currency": new_balance_in_base_currency,
- }
return account_details
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json b/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json
index 30ff9ebed5..2968359a0d 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json
@@ -1,475 +1,161 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
+ "actions": [],
"creation": "2018-04-13 18:30:06.110433",
- "custom": 0,
- "docstatus": 0,
"doctype": "DocType",
- "document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
+ "field_order": [
+ "account",
+ "party_type",
+ "party",
+ "column_break_2",
+ "account_currency",
+ "account_balances",
+ "balance_in_account_currency",
+ "column_break_46yz",
+ "new_balance_in_account_currency",
+ "balances",
+ "current_exchange_rate",
+ "column_break_xown",
+ "new_exchange_rate",
+ "column_break_9",
+ "balance_in_base_currency",
+ "column_break_ukce",
+ "new_balance_in_base_currency",
+ "section_break_ngrs",
+ "gain_loss",
+ "zero_balance"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "account",
"fieldtype": "Link",
- "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": "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": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "reqd": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "party_type",
"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": "Party Type",
- "length": 0,
- "no_copy": 0,
- "options": "DocType",
- "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
+ "options": "DocType"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "party",
"fieldtype": "Dynamic 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": "Party",
- "length": 0,
- "no_copy": 0,
- "options": "party_type",
- "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
+ "options": "party_type"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "column_break_2",
- "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
+ "fieldtype": "Column Break"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "account_currency",
"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": "Account Currency",
- "length": 0,
- "no_copy": 0,
"options": "Currency",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "read_only": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "balance_in_account_currency",
"fieldtype": "Currency",
- "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": "Balance In Account Currency",
- "length": 0,
- "no_copy": 0,
"options": "account_currency",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "read_only": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "balances",
- "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": "",
- "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
+ "fieldtype": "Section Break"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "current_exchange_rate",
"fieldtype": "Float",
- "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": "Current Exchange Rate",
- "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": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "read_only": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "balance_in_base_currency",
"fieldtype": "Currency",
- "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": "Balance In Base Currency",
- "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": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "options": "Company:company:default_currency",
+ "read_only": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "column_break_9",
- "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
+ "fieldtype": "Section Break"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "new_exchange_rate",
"fieldtype": "Float",
- "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": "New Exchange Rate",
- "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
+ "reqd": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "new_balance_in_base_currency",
"fieldtype": "Currency",
- "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": "New Balance In Base Currency",
- "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": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "options": "Company:company:default_currency",
+ "read_only": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "gain_loss",
"fieldtype": "Currency",
- "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": "Gain/Loss",
- "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": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "description": "This Account has '0' balance in either Base Currency or Account Currency",
+ "fieldname": "zero_balance",
+ "fieldtype": "Check",
+ "label": "Zero Balance"
+ },
+ {
+ "fieldname": "new_balance_in_account_currency",
+ "fieldtype": "Currency",
+ "label": "New Balance In Account Currency",
+ "options": "account_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "account_balances",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_46yz",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_xown",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_ukce",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_ngrs",
+ "fieldtype": "Section Break"
}
],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
"istable": 1,
- "max_attachments": 0,
- "modified": "2019-06-26 18:57:51.762345",
+ "links": [],
+ "modified": "2022-12-29 19:38:52.915295",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Exchange Rate Revaluation Account",
- "name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py
index 069ab5ea84..3207e4195e 100644
--- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py
+++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py
@@ -9,10 +9,6 @@ from frappe.model.document import Document
from frappe.utils import add_days, add_years, cstr, getdate
-class FiscalYearIncorrectDate(frappe.ValidationError):
- pass
-
-
class FiscalYear(Document):
@frappe.whitelist()
def set_as_default(self):
@@ -53,23 +49,18 @@ class FiscalYear(Document):
)
def validate_dates(self):
+ self.validate_from_to_dates("year_start_date", "year_end_date")
if self.is_short_year:
# Fiscal Year can be shorter than one year, in some jurisdictions
# under certain circumstances. For example, in the USA and Germany.
return
- if getdate(self.year_start_date) > getdate(self.year_end_date):
- frappe.throw(
- _("Fiscal Year Start Date should be one year earlier than Fiscal Year End Date"),
- FiscalYearIncorrectDate,
- )
-
date = getdate(self.year_start_date) + relativedelta(years=1) - relativedelta(days=1)
if getdate(self.year_end_date) != date:
frappe.throw(
_("Fiscal Year End Date should be one year after Fiscal Year Start Date"),
- FiscalYearIncorrectDate,
+ frappe.exceptions.InvalidDates,
)
def on_update(self):
@@ -169,5 +160,6 @@ def auto_create_fiscal_year():
def get_from_and_to_date(fiscal_year):
- fields = ["year_start_date as from_date", "year_end_date as to_date"]
- return frappe.db.get_value("Fiscal Year", fiscal_year, fields, as_dict=1)
+ fields = ["year_start_date", "year_end_date"]
+ cached_results = frappe.get_cached_value("Fiscal Year", fiscal_year, fields, as_dict=1)
+ return dict(from_date=cached_results.year_start_date, to_date=cached_results.year_end_date)
diff --git a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
index 6e946f7466..181406bad7 100644
--- a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
+++ b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
@@ -7,8 +7,6 @@ import unittest
import frappe
from frappe.utils import now_datetime
-from erpnext.accounts.doctype.fiscal_year.fiscal_year import FiscalYearIncorrectDate
-
test_ignore = ["Company"]
@@ -26,7 +24,7 @@ class TestFiscalYear(unittest.TestCase):
}
)
- self.assertRaises(FiscalYearIncorrectDate, fy.insert)
+ self.assertRaises(frappe.exceptions.InvalidDates, fy.insert)
def test_record_generator():
@@ -35,8 +33,8 @@ def test_record_generator():
"doctype": "Fiscal Year",
"year": "_Test Short Fiscal Year 2011",
"is_short_year": 1,
- "year_end_date": "2011-04-01",
- "year_start_date": "2011-12-31",
+ "year_start_date": "2011-04-01",
+ "year_end_date": "2011-12-31",
}
]
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py
index 1987c8340d..f07a4fa3bc 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.py
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py
@@ -58,7 +58,7 @@ class GLEntry(Document):
validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj)
- if frappe.db.get_value("Account", self.account, "account_type") not in [
+ if frappe.get_cached_value("Account", self.account, "account_type") not in [
"Receivable",
"Payable",
]:
@@ -95,7 +95,15 @@ class GLEntry(Document):
)
# Zero value transaction is not allowed
- if not (flt(self.debit, self.precision("debit")) or flt(self.credit, self.precision("credit"))):
+ if not (
+ flt(self.debit, self.precision("debit"))
+ or flt(self.credit, self.precision("credit"))
+ or (
+ self.voucher_type == "Journal Entry"
+ and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type")
+ == "Exchange Gain Or Loss"
+ )
+ ):
frappe.throw(
_("{0} {1}: Either debit or credit amount is required for {2}").format(
self.voucher_type, self.voucher_no, self.account
@@ -120,7 +128,7 @@ class GLEntry(Document):
frappe.throw(msg, title=_("Missing Cost Center"))
def validate_dimensions_for_pl_and_bs(self):
- account_type = frappe.db.get_value("Account", self.account, "report_type")
+ account_type = frappe.get_cached_value("Account", self.account, "report_type")
for dimension in get_checks_for_pl_and_bs_accounts():
if (
@@ -188,7 +196,7 @@ class GLEntry(Document):
def check_pl_account(self):
if (
self.is_opening == "Yes"
- and frappe.db.get_value("Account", self.account, "report_type") == "Profit and Loss"
+ and frappe.get_cached_value("Account", self.account, "report_type") == "Profit and Loss"
and not self.is_cancelled
):
frappe.throw(
@@ -281,7 +289,7 @@ class GLEntry(Document):
def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
- balance_must_be = frappe.db.get_value("Account", account, "balance_must_be")
+ balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
if balance_must_be:
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
@@ -366,7 +374,7 @@ def update_outstanding_amt(
if against_voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"]:
ref_doc = frappe.get_doc(against_voucher_type, against_voucher)
- # Didn't use db_set for optimisation purpose
+ # Didn't use db_set for optimization purpose
ref_doc.outstanding_amount = bal
frappe.db.set_value(against_voucher_type, against_voucher, "outstanding_amount", bal)
diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py
index 23f36ec6d8..7e2fca8d24 100644
--- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py
+++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py
@@ -21,7 +21,7 @@ class ItemTaxTemplate(Document):
check_list = []
for d in self.get("taxes"):
if d.tax_type:
- account_type = frappe.db.get_value("Account", d.tax_type, "account_type")
+ account_type = frappe.get_cached_value("Account", d.tax_type, "account_type")
if account_type not in [
"Tax",
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index 7af41f398a..089f20b467 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry");
frappe.ui.form.on("Journal Entry", {
setup: function(frm) {
frm.add_fetch("bank_account", "account", "account");
- frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
+ frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger"];
},
refresh: function(frm) {
@@ -173,8 +173,8 @@ frappe.ui.form.on("Journal Entry", {
var update_jv_details = function(doc, r) {
$.each(r, function(i, d) {
var row = frappe.model.add_child(doc, "Journal Entry Account", "accounts");
- row.account = d.account;
- row.balance = d.balance;
+ frappe.model.set_value(row.doctype, row.name, "account", d.account)
+ frappe.model.set_value(row.doctype, row.name, "balance", d.balance)
});
refresh_field("accounts");
}
@@ -253,9 +253,6 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
var party_account_field = jvd.reference_type==="Sales Invoice" ? "debit_to": "credit_to";
out.filters.push([jvd.reference_type, party_account_field, "=", jvd.account]);
- if (in_list(['Debit Note', 'Credit Note'], doc.voucher_type)) {
- out.filters.push([jvd.reference_type, "is_return", "=", 1]);
- }
}
if(in_list(["Sales Order", "Purchase Order"], jvd.reference_type)) {
@@ -312,8 +309,7 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
}
}
- get_outstanding(doctype, docname, company, child, due_date) {
- var me = this;
+ get_outstanding(doctype, docname, company, child) {
var args = {
"doctype": doctype,
"docname": docname,
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json
index 8e5ba3718f..498fc7c295 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.json
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json
@@ -88,7 +88,7 @@
"label": "Entry Type",
"oldfieldname": "voucher_type",
"oldfieldtype": "Select",
- "options": "Journal Entry\nInter Company Journal Entry\nBank Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nDeferred Revenue\nDeferred Expense",
+ "options": "Journal Entry\nInter Company Journal Entry\nBank Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nExchange Gain Or Loss\nDeferred Revenue\nDeferred Expense",
"reqd": 1,
"search_index": 1
},
@@ -137,8 +137,7 @@
"fieldname": "finance_book",
"fieldtype": "Link",
"label": "Finance Book",
- "options": "Finance Book",
- "read_only": 1
+ "options": "Finance Book"
},
{
"fieldname": "2_add_edit_gl_entries",
@@ -539,7 +538,7 @@
"idx": 176,
"is_submittable": 1,
"links": [],
- "modified": "2022-06-23 22:01:32.348337",
+ "modified": "2023-01-17 12:53:53.280620",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 63c6547f1d..db399b7bad 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -6,7 +6,7 @@ import json
import frappe
from frappe import _, msgprint, scrub
-from frappe.utils import cint, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
+from frappe.utils import cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
@@ -23,6 +23,9 @@ from erpnext.accounts.utils import (
get_stock_accounts,
get_stock_and_account_balance,
)
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_depr_schedule,
+)
from erpnext.controllers.accounts_controller import AccountsController
@@ -34,9 +37,6 @@ class JournalEntry(AccountsController):
def __init__(self, *args, **kwargs):
super(JournalEntry, self).__init__(*args, **kwargs)
- def get_feed(self):
- return self.voucher_type
-
def validate(self):
if self.voucher_type == "Opening Entry":
self.is_opening = "Yes"
@@ -81,6 +81,7 @@ class JournalEntry(AccountsController):
self.check_credit_limit()
self.make_gl_entries()
self.update_advance_paid()
+ self.update_asset_value()
self.update_inter_company_jv()
self.update_invoice_discounting()
@@ -88,7 +89,13 @@ class JournalEntry(AccountsController):
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
unlink_ref_doc_from_payment_entries(self)
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
+ self.ignore_linked_doctypes = (
+ "GL Entry",
+ "Stock Ledger Entry",
+ "Payment Ledger Entry",
+ "Repost Payment Ledger",
+ "Repost Payment Ledger Items",
+ )
self.make_gl_entries(1)
self.update_advance_paid()
self.unlink_advance_entry_reference()
@@ -184,7 +191,9 @@ class JournalEntry(AccountsController):
}
)
- tax_withholding_details = get_party_tax_withholding_details(inv, self.tax_withholding_category)
+ tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
+ inv, self.tax_withholding_category
+ )
if not tax_withholding_details:
return
@@ -223,6 +232,29 @@ class JournalEntry(AccountsController):
for d in to_remove:
self.remove(d)
+ def update_asset_value(self):
+ if self.voucher_type != "Depreciation Entry":
+ return
+
+ processed_assets = []
+
+ for d in self.get("accounts"):
+ if (
+ d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets
+ ):
+ processed_assets.append(d.reference_name)
+
+ asset = frappe.get_doc("Asset", d.reference_name)
+
+ if asset.calculate_depreciation:
+ continue
+
+ depr_value = d.debit or d.credit
+
+ asset.db_set("value_after_depreciation", asset.value_after_depreciation - depr_value)
+
+ asset.set_status()
+
def update_inter_company_jv(self):
if (
self.voucher_type == "Inter Company Journal Entry"
@@ -281,19 +313,45 @@ class JournalEntry(AccountsController):
d.db_update()
def unlink_asset_reference(self):
+ if self.voucher_type != "Depreciation Entry":
+ return
+
+ processed_assets = []
+
for d in self.get("accounts"):
- if d.reference_type == "Asset" and d.reference_name:
+ if (
+ d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets
+ ):
+ processed_assets.append(d.reference_name)
+
asset = frappe.get_doc("Asset", d.reference_name)
- for s in asset.get("schedules"):
- if s.journal_entry == self.name:
- s.db_set("journal_entry", None)
- idx = cint(s.finance_book_id) or 1
- finance_books = asset.get("finance_books")[idx - 1]
- finance_books.value_after_depreciation += s.depreciation_amount
- finance_books.db_update()
+ if asset.calculate_depreciation:
+ je_found = False
- asset.set_status()
+ for row in asset.get("finance_books"):
+ if je_found:
+ break
+
+ depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book)
+
+ for s in depr_schedule or []:
+ if s.journal_entry == self.name:
+ s.db_set("journal_entry", None)
+
+ row.value_after_depreciation += s.depreciation_amount
+ row.db_update()
+
+ asset.set_status()
+
+ je_found = True
+ break
+ else:
+ depr_value = d.debit or d.credit
+
+ asset.db_set("value_after_depreciation", asset.value_after_depreciation + depr_value)
+
+ asset.set_status()
def unlink_inter_company_jv(self):
if (
@@ -317,7 +375,7 @@ class JournalEntry(AccountsController):
def validate_party(self):
for d in self.get("accounts"):
- account_type = frappe.db.get_value("Account", d.account, "account_type")
+ account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"]:
if not (d.party_type and d.party):
frappe.throw(
@@ -380,7 +438,7 @@ class JournalEntry(AccountsController):
def validate_against_jv(self):
for d in self.get("accounts"):
if d.reference_type == "Journal Entry":
- account_root_type = frappe.db.get_value("Account", d.account, "root_type")
+ account_root_type = frappe.get_cached_value("Account", d.account, "root_type")
if account_root_type == "Asset" and flt(d.debit) > 0:
frappe.throw(
_(
@@ -590,28 +648,30 @@ class JournalEntry(AccountsController):
d.against_account = frappe.db.get_value(d.reference_type, d.reference_name, field)
else:
for d in self.get("accounts"):
- if flt(d.debit > 0):
+ if flt(d.debit) > 0:
accounts_debited.append(d.party or d.account)
if flt(d.credit) > 0:
accounts_credited.append(d.party or d.account)
for d in self.get("accounts"):
- if flt(d.debit > 0):
+ if flt(d.debit) > 0:
d.against_account = ", ".join(list(set(accounts_credited)))
- if flt(d.credit > 0):
+ if flt(d.credit) > 0:
d.against_account = ", ".join(list(set(accounts_debited)))
def validate_debit_credit_amount(self):
- for d in self.get("accounts"):
- if not flt(d.debit) and not flt(d.credit):
- frappe.throw(_("Row {0}: Both Debit and Credit values cannot be zero").format(d.idx))
+ if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency):
+ for d in self.get("accounts"):
+ if not flt(d.debit) and not flt(d.credit):
+ frappe.throw(_("Row {0}: Both Debit and Credit values cannot be zero").format(d.idx))
def validate_total_debit_and_credit(self):
self.set_total_debit_credit()
- if self.difference:
- frappe.throw(
- _("Total Debit must be equal to Total Credit. The difference is {0}").format(self.difference)
- )
+ if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency):
+ if self.difference:
+ frappe.throw(
+ _("Total Debit must be equal to Total Credit. The difference is {0}").format(self.difference)
+ )
def set_total_debit_credit(self):
self.total_debit, self.total_credit, self.difference = 0, 0, 0
@@ -629,7 +689,7 @@ class JournalEntry(AccountsController):
def validate_multi_currency(self):
alternate_currency = []
for d in self.get("accounts"):
- account = frappe.db.get_value(
+ account = frappe.get_cached_value(
"Account", d.account, ["account_currency", "account_type"], as_dict=1
)
if account:
@@ -649,16 +709,17 @@ class JournalEntry(AccountsController):
self.set_exchange_rate()
def set_amounts_in_company_currency(self):
- for d in self.get("accounts"):
- d.debit_in_account_currency = flt(
- d.debit_in_account_currency, d.precision("debit_in_account_currency")
- )
- d.credit_in_account_currency = flt(
- d.credit_in_account_currency, d.precision("credit_in_account_currency")
- )
+ if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency):
+ for d in self.get("accounts"):
+ d.debit_in_account_currency = flt(
+ d.debit_in_account_currency, d.precision("debit_in_account_currency")
+ )
+ d.credit_in_account_currency = flt(
+ d.credit_in_account_currency, d.precision("credit_in_account_currency")
+ )
- d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
- d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
+ d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
+ d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
def set_exchange_rate(self):
for d in self.get("accounts"):
@@ -757,10 +818,10 @@ class JournalEntry(AccountsController):
pay_to_recd_from = d.party
if pay_to_recd_from and pay_to_recd_from == d.party:
- party_amount += d.debit_in_account_currency or d.credit_in_account_currency
+ party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
party_account_currency = d.account_currency
- elif frappe.db.get_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
+ elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += d.debit_in_account_currency or d.credit_in_account_currency
bank_account_currency = d.account_currency
@@ -787,7 +848,7 @@ class JournalEntry(AccountsController):
def build_gl_map(self):
gl_map = []
for d in self.get("accounts"):
- if d.debit or d.credit:
+ if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, self.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
@@ -835,7 +896,7 @@ class JournalEntry(AccountsController):
make_gl_entries(gl_map, cancel=cancel, adv_adj=adv_adj, update_outstanding=update_outstanding)
@frappe.whitelist()
- def get_balance(self):
+ def get_balance(self, difference_account=None):
if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
else:
@@ -850,7 +911,13 @@ class JournalEntry(AccountsController):
blank_row = d
if not blank_row:
- blank_row = self.append("accounts", {})
+ blank_row = self.append(
+ "accounts",
+ {
+ "account": difference_account,
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ },
+ )
blank_row.exchange_rate = 1
if diff > 0:
@@ -985,7 +1052,7 @@ def get_default_bank_cash_account(company, account_type=None, mode_of_payment=No
account = account_list[0].name
if account:
- account_details = frappe.db.get_value(
+ account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1114,7 +1181,7 @@ def get_payment_entry(ref_doc, args):
"party_type": args.get("party_type"),
"party": ref_doc.get(args.get("party_type").lower()),
"cost_center": cost_center,
- "account_type": frappe.db.get_value("Account", args.get("party_account"), "account_type"),
+ "account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
"account_currency": args.get("party_account_currency")
or get_account_currency(args.get("party_account")),
"balance": get_balance_on(args.get("party_account")),
@@ -1208,6 +1275,7 @@ def get_outstanding(args):
args = json.loads(args)
company_currency = erpnext.get_company_currency(args.get("company"))
+ due_date = None
if args.get("doctype") == "Journal Entry":
condition = " and party=%(party)s" if args.get("party") else ""
@@ -1232,10 +1300,12 @@ def get_outstanding(args):
invoice = frappe.db.get_value(
args["doctype"],
args["docname"],
- ["outstanding_amount", "conversion_rate", scrub(party_type)],
+ ["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
+ due_date = invoice.get("due_date")
+
exchange_rate = (
invoice.conversion_rate if (args.get("account_currency") != company_currency) else 1
)
@@ -1258,6 +1328,7 @@ def get_outstanding(args):
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
+ "reference_due_date": due_date,
}
@@ -1277,7 +1348,7 @@ def get_party_account_and_balance(company, party_type, party, cost_center=None):
"account": account,
"balance": account_balance,
"party_balance": party_balance,
- "account_currency": frappe.db.get_value("Account", account, "account_currency"),
+ "account_currency": frappe.get_cached_value("Account", account, "account_currency"),
}
@@ -1290,7 +1361,7 @@ def get_account_balance_and_party_type(
frappe.msgprint(_("No Permission"), raise_exception=1)
company_currency = erpnext.get_company_currency(company)
- account_details = frappe.db.get_value(
+ account_details = frappe.get_cached_value(
"Account", account, ["account_type", "account_currency"], as_dict=1
)
@@ -1343,7 +1414,7 @@ def get_exchange_rate(
):
from erpnext.setup.utils import get_exchange_rate
- account_details = frappe.db.get_value(
+ account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
)
diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
index dff883aef9..47ad19e0f9 100644
--- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
+++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
@@ -202,6 +202,7 @@
"fieldname": "reference_type",
"fieldtype": "Select",
"label": "Reference Type",
+ "no_copy": 1,
"options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement"
},
{
@@ -209,13 +210,15 @@
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
+ "no_copy": 1,
"options": "reference_type"
},
{
"depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance'])",
"fieldname": "reference_due_date",
- "fieldtype": "Select",
- "label": "Reference Due Date"
+ "fieldtype": "Date",
+ "label": "Reference Due Date",
+ "no_copy": 1
},
{
"fieldname": "project",
@@ -274,19 +277,22 @@
"fieldname": "reference_detail_no",
"fieldtype": "Data",
"hidden": 1,
- "label": "Reference Detail No"
+ "label": "Reference Detail No",
+ "no_copy": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-08-30 21:27:32.200299",
+ "modified": "2022-10-26 20:03:10.906259",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js b/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js
index 1c19c1d225..88f1c9069c 100644
--- a/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js
+++ b/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js
@@ -2,7 +2,7 @@
// For license information, please see license.txt
frappe.ui.form.on("Journal Entry Template", {
- setup: function(frm) {
+ refresh: function(frm) {
frappe.model.set_default_values(frm.doc);
frm.set_query("account" ,"accounts", function(){
@@ -45,21 +45,6 @@ frappe.ui.form.on("Journal Entry Template", {
frm.trigger("clear_child");
switch(frm.doc.voucher_type){
- case "Opening Entry":
- frm.set_value("is_opening", "Yes");
- frappe.call({
- type:"GET",
- method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_opening_accounts",
- args: {
- "company": frm.doc.company
- },
- callback: function(r) {
- if(r.message) {
- add_accounts(frm.doc, r.message);
- }
- }
- });
- break;
case "Bank Entry":
case "Cash Entry":
frappe.call({
diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
index 18e5a1ac85..7cd6d04c35 100644
--- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
+++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
@@ -4,22 +4,20 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.utils.background_jobs import is_job_queued
from erpnext.accounts.doctype.account.account import merge_account
class LedgerMerge(Document):
def start_merge(self):
- from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.utils.background_jobs import enqueue
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot merge accounts."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
-
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_merge,
queue="default",
diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
index ed35d1e094..7d6ef3c3bf 100644
--- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
+++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
@@ -25,7 +25,7 @@ class ModeofPayment(Document):
def validate_accounts(self):
for entry in self.accounts:
"""Error when Company of Ledger account doesn't match with Company Selected"""
- if frappe.db.get_value("Account", entry.default_account, "company") != entry.company:
+ if frappe.get_cached_value("Account", entry.default_account, "company") != entry.company:
frappe.throw(
_("Account {0} does not match with Company {1} in Mode of Account: {2}").format(
entry.default_account, entry.company, self.name
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
index 7eb5c4234d..88867d11bb 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
@@ -20,15 +20,14 @@ frappe.ui.form.on('Opening Invoice Creation Tool', {
frm.dashboard.reset();
frm.doc.import_in_progress = true;
}
- if (data.user != frappe.session.user) return;
if (data.count == data.total) {
- setTimeout((title) => {
+ setTimeout(() => {
frm.doc.import_in_progress = false;
frm.clear_table("invoices");
frm.refresh_fields();
frm.page.clear_indicator();
- frm.dashboard.hide_progress(title);
- frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type]));
+ frm.dashboard.hide_progress();
+ frappe.msgprint(__("Opening {0} Invoices created", [frm.doc.invoice_type]));
}, 1500, data.title);
return;
}
@@ -51,13 +50,6 @@ frappe.ui.form.on('Opening Invoice Creation Tool', {
method: "make_invoices",
freeze: 1,
freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]),
- callback: function(r) {
- if (r.message.length == 1) {
- frappe.msgprint(__("{0} Invoice created successfully.", [frm.doc.invoice_type]));
- } else if (r.message.length < 50) {
- frappe.msgprint(__("{0} Invoices created successfully.", [frm.doc.invoice_type]));
- }
- }
});
});
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index 99377421c5..47c2ceb6e4 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -6,7 +6,7 @@ import frappe
from frappe import _, scrub
from frappe.model.document import Document
from frappe.utils import flt, nowdate
-from frappe.utils.background_jobs import enqueue
+from frappe.utils.background_jobs import enqueue, is_job_queued
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -207,14 +207,12 @@ class OpeningInvoiceCreationTool(Document):
if len(invoices) < 50:
return start_import(invoices)
else:
- from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_import,
queue="default",
@@ -257,17 +255,15 @@ def start_import(invoices):
def publish(index, total, doctype):
- if total < 50:
- return
frappe.publish_realtime(
"opening_invoice_creation_progress",
dict(
title=_("Opening Invoice Creation In Progress"),
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
- user=frappe.session.user,
count=index + 1,
total=total,
),
+ user=frappe.session.user,
)
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 0f53079403..91374ae217 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -7,7 +7,7 @@ cur_frm.cscript.tax_table = "Advance Taxes and Charges";
frappe.ui.form.on('Payment Entry', {
onload: function(frm) {
- frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
+ frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"];
if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@@ -1091,7 +1091,7 @@ frappe.ui.form.on('Payment Entry', {
$.each(tax_fields, function(i, fieldname) { tax[fieldname] = 0.0; });
- frm.doc.paid_amount_after_tax = frm.doc.paid_amount;
+ frm.doc.paid_amount_after_tax = frm.doc.base_paid_amount;
});
},
@@ -1182,7 +1182,7 @@ frappe.ui.form.on('Payment Entry', {
}
cumulated_tax_fraction += tax.tax_fraction_for_current_item;
- frm.doc.paid_amount_after_tax = flt(frm.doc.paid_amount/(1+cumulated_tax_fraction))
+ frm.doc.paid_amount_after_tax = flt(frm.doc.base_paid_amount/(1+cumulated_tax_fraction))
});
},
@@ -1214,6 +1214,7 @@ frappe.ui.form.on('Payment Entry', {
frm.doc.total_taxes_and_charges = 0.0;
frm.doc.base_total_taxes_and_charges = 0.0;
+ let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
let actual_tax_dict = {};
// maintain actual tax rate based on idx
@@ -1234,8 +1235,8 @@ frappe.ui.form.on('Payment Entry', {
}
}
- tax.tax_amount = current_tax_amount;
- tax.base_tax_amount = tax.tax_amount * frm.doc.source_exchange_rate;
+ // tax accounts are only in company currency
+ tax.base_tax_amount = current_tax_amount;
current_tax_amount *= (tax.add_deduct_tax == "Deduct") ? -1.0 : 1.0;
if(i==0) {
@@ -1244,9 +1245,29 @@ frappe.ui.form.on('Payment Entry', {
tax.total = flt(frm.doc["taxes"][i-1].total + current_tax_amount, precision("total", tax));
}
- tax.base_total = tax.total * frm.doc.source_exchange_rate;
- frm.doc.total_taxes_and_charges += current_tax_amount;
- frm.doc.base_total_taxes_and_charges += current_tax_amount * frm.doc.source_exchange_rate;
+ // tac accounts are only in company currency
+ tax.base_total = tax.total
+
+ // calculate total taxes and base total taxes
+ if(frm.doc.payment_type == "Pay") {
+ // tax accounts only have company currency
+ if(tax.currency != frm.doc.paid_to_account_currency) {
+ //total_taxes_and_charges has the target currency. so using target conversion rate
+ frm.doc.total_taxes_and_charges += flt(current_tax_amount / frm.doc.target_exchange_rate);
+
+ } else {
+ frm.doc.total_taxes_and_charges += current_tax_amount;
+ }
+ } else if(frm.doc.payment_type == "Receive") {
+ if(tax.currency != frm.doc.paid_from_account_currency) {
+ //total_taxes_and_charges has the target currency. so using source conversion rate
+ frm.doc.total_taxes_and_charges += flt(current_tax_amount / frm.doc.source_exchange_rate);
+ } else {
+ frm.doc.total_taxes_and_charges += current_tax_amount;
+ }
+ }
+
+ frm.doc.base_total_taxes_and_charges += tax.base_tax_amount;
frm.refresh_field('taxes');
frm.refresh_field('total_taxes_and_charges');
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json
index 3fc1adff2d..3927ecae43 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.json
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json
@@ -239,7 +239,7 @@
"depends_on": "paid_from",
"fieldname": "paid_from_account_currency",
"fieldtype": "Link",
- "label": "Account Currency",
+ "label": "Account Currency (From)",
"options": "Currency",
"print_hide": 1,
"read_only": 1,
@@ -249,7 +249,7 @@
"depends_on": "paid_from",
"fieldname": "paid_from_account_balance",
"fieldtype": "Currency",
- "label": "Account Balance",
+ "label": "Account Balance (From)",
"options": "paid_from_account_currency",
"print_hide": 1,
"read_only": 1
@@ -272,7 +272,7 @@
"depends_on": "paid_to",
"fieldname": "paid_to_account_currency",
"fieldtype": "Link",
- "label": "Account Currency",
+ "label": "Account Currency (To)",
"options": "Currency",
"print_hide": 1,
"read_only": 1,
@@ -282,7 +282,7 @@
"depends_on": "paid_to",
"fieldname": "paid_to_account_balance",
"fieldtype": "Currency",
- "label": "Account Balance",
+ "label": "Account Balance (To)",
"options": "paid_to_account_currency",
"print_hide": 1,
"read_only": 1
@@ -304,7 +304,8 @@
{
"fieldname": "source_exchange_rate",
"fieldtype": "Float",
- "label": "Exchange Rate",
+ "label": "Source Exchange Rate",
+ "precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -333,7 +334,8 @@
{
"fieldname": "target_exchange_rate",
"fieldtype": "Float",
- "label": "Exchange Rate",
+ "label": "Target Exchange Rate",
+ "precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -631,14 +633,14 @@
"depends_on": "eval:doc.party_type == 'Supplier'",
"fieldname": "purchase_taxes_and_charges_template",
"fieldtype": "Link",
- "label": "Taxes and Charges Template",
+ "label": "Purchase Taxes and Charges Template",
"options": "Purchase Taxes and Charges Template"
},
{
"depends_on": "eval: doc.party_type == 'Customer'",
"fieldname": "sales_taxes_and_charges_template",
"fieldtype": "Link",
- "label": "Taxes and Charges Template",
+ "label": "Sales Taxes and Charges Template",
"options": "Sales Taxes and Charges Template"
},
{
@@ -731,7 +733,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-02-23 20:08:39.559814",
+ "modified": "2023-02-14 04:52:30.478523",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index af5a5e249d..cd5b6d5ce2 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -62,7 +62,6 @@ class PaymentEntry(AccountsController):
self.set_missing_values()
self.validate_payment_type()
self.validate_party_details()
- self.validate_bank_accounts()
self.set_exchange_rate()
self.validate_mandatory()
self.validate_reference_documents()
@@ -93,7 +92,13 @@ class PaymentEntry(AccountsController):
self.set_status()
def on_cancel(self):
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
+ self.ignore_linked_doctypes = (
+ "GL Entry",
+ "Stock Ledger Entry",
+ "Payment Ledger Entry",
+ "Repost Payment Ledger",
+ "Repost Payment Ledger Items",
+ )
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_advance_paid()
@@ -181,7 +186,11 @@ class PaymentEntry(AccountsController):
frappe.throw(_("Party is mandatory"))
_party_name = "title" if self.party_type == "Shareholder" else self.party_type.lower() + "_name"
- self.party_name = frappe.db.get_value(self.party_type, self.party, _party_name)
+
+ if frappe.db.has_column(self.party_type, _party_name):
+ self.party_name = frappe.db.get_value(self.party_type, self.party, _party_name)
+ else:
+ self.party_name = frappe.db.get_value(self.party_type, self.party, "name")
if self.party:
if not self.party_balance:
@@ -239,29 +248,12 @@ class PaymentEntry(AccountsController):
if not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("Invalid {0}: {1}").format(self.party_type, self.party))
- if self.party_account and self.party_type in ("Customer", "Supplier"):
- self.validate_account_type(
- self.party_account, [erpnext.get_party_account_type(self.party_type)]
- )
-
- def validate_bank_accounts(self):
- if self.payment_type in ("Pay", "Internal Transfer"):
- self.validate_account_type(self.paid_from, ["Bank", "Cash"])
-
- if self.payment_type in ("Receive", "Internal Transfer"):
- self.validate_account_type(self.paid_to, ["Bank", "Cash"])
-
- def validate_account_type(self, account, account_types):
- account_type = frappe.db.get_value("Account", account, "account_type")
- # if account_type not in account_types:
- # frappe.throw(_("Account Type for {0} must be {1}").format(account, comma_or(account_types)))
-
def set_exchange_rate(self, ref_doc=None):
self.set_source_exchange_rate(ref_doc)
self.set_target_exchange_rate(ref_doc)
def set_source_exchange_rate(self, ref_doc=None):
- if self.paid_from and not self.source_exchange_rate:
+ if self.paid_from:
if self.paid_from_account_currency == self.company_currency:
self.source_exchange_rate = 1
else:
@@ -295,6 +287,9 @@ class PaymentEntry(AccountsController):
def validate_reference_documents(self):
valid_reference_doctypes = self.get_valid_reference_doctypes()
+ if not valid_reference_doctypes:
+ return
+
for d in self.get("references"):
if not d.allocated_amount:
continue
@@ -362,7 +357,7 @@ class PaymentEntry(AccountsController):
if not d.allocated_amount:
continue
- if d.reference_doctype in ("Sales Invoice", "Purchase Invoice", "Fees"):
+ if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
outstanding_amount, is_return = frappe.get_cached_value(
d.reference_doctype, d.reference_name, ["outstanding_amount", "is_return"]
)
@@ -633,7 +628,7 @@ class PaymentEntry(AccountsController):
self.payment_type == "Receive"
and self.base_total_allocated_amount < self.base_received_amount + total_deductions
and self.total_allocated_amount
- < self.paid_amount + (total_deductions / self.source_exchange_rate)
+ < flt(self.paid_amount) + (total_deductions / self.source_exchange_rate)
):
self.unallocated_amount = (
self.base_received_amount + total_deductions - self.base_total_allocated_amount
@@ -643,7 +638,7 @@ class PaymentEntry(AccountsController):
self.payment_type == "Pay"
and self.base_total_allocated_amount < (self.base_paid_amount - total_deductions)
and self.total_allocated_amount
- < self.received_amount + (total_deductions / self.target_exchange_rate)
+ < flt(self.received_amount) + (total_deductions / self.target_exchange_rate)
):
self.unallocated_amount = (
self.base_paid_amount - (total_deductions + self.base_total_allocated_amount)
@@ -695,35 +690,34 @@ class PaymentEntry(AccountsController):
)
def validate_payment_against_negative_invoice(self):
- if (self.payment_type == "Pay" and self.party_type == "Customer") or (
- self.payment_type == "Receive" and self.party_type == "Supplier"
+ if (self.payment_type != "Pay" or self.party_type != "Customer") and (
+ self.payment_type != "Receive" or self.party_type != "Supplier"
):
+ return
- total_negative_outstanding = sum(
- abs(flt(d.outstanding_amount)) for d in self.get("references") if flt(d.outstanding_amount) < 0
+ total_negative_outstanding = sum(
+ abs(flt(d.outstanding_amount)) for d in self.get("references") if flt(d.outstanding_amount) < 0
+ )
+
+ paid_amount = self.paid_amount if self.payment_type == "Receive" else self.received_amount
+ additional_charges = sum(flt(d.amount) for d in self.deductions)
+
+ if not total_negative_outstanding:
+ if self.party_type == "Customer":
+ msg = _("Cannot pay to Customer without any negative outstanding invoice")
+ else:
+ msg = _("Cannot receive from Supplier without any negative outstanding invoice")
+
+ frappe.throw(msg, InvalidPaymentEntry)
+
+ elif paid_amount - additional_charges > total_negative_outstanding:
+ frappe.throw(
+ _("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
+ total_negative_outstanding
+ ),
+ InvalidPaymentEntry,
)
- paid_amount = self.paid_amount if self.payment_type == "Receive" else self.received_amount
- additional_charges = sum([flt(d.amount) for d in self.deductions])
-
- if not total_negative_outstanding:
- frappe.throw(
- _("Cannot {0} {1} {2} without any negative outstanding invoice").format(
- _(self.payment_type),
- (_("to") if self.party_type == "Customer" else _("from")),
- self.party_type,
- ),
- InvalidPaymentEntry,
- )
-
- elif paid_amount - additional_charges > total_negative_outstanding:
- frappe.throw(
- _("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
- total_negative_outstanding
- ),
- InvalidPaymentEntry,
- )
-
def set_title(self):
if frappe.flags.in_import and self.title:
# do not set title dynamically if title exists during data import.
@@ -736,7 +730,7 @@ class PaymentEntry(AccountsController):
def validate_transaction_reference(self):
bank_account = self.paid_to if self.payment_type == "Receive" else self.paid_from
- bank_account_type = frappe.db.get_value("Account", bank_account, "account_type")
+ bank_account_type = frappe.get_cached_value("Account", bank_account, "account_type")
if bank_account_type == "Bank":
if not self.reference_no or not self.reference_date:
@@ -933,6 +927,13 @@ class PaymentEntry(AccountsController):
)
if not d.included_in_paid_amount:
+ if get_account_currency(payment_account) != self.company_currency:
+ if self.payment_type == "Receive":
+ exchange_rate = self.target_exchange_rate
+ elif self.payment_type in ["Pay", "Internal Transfer"]:
+ exchange_rate = self.source_exchange_rate
+ base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))
+
gl_entries.append(
self.get_gl_dict(
{
@@ -981,7 +982,9 @@ class PaymentEntry(AccountsController):
if self.payment_type in ("Receive", "Pay") and self.party:
for d in self.get("references"):
if d.allocated_amount and d.reference_doctype in frappe.get_hooks("advance_payment_doctypes"):
- frappe.get_doc(d.reference_doctype, d.reference_name).set_total_advance_paid()
+ frappe.get_doc(
+ d.reference_doctype, d.reference_name, for_update=True
+ ).set_total_advance_paid()
def on_recurring(self, reference_doc, auto_repeat_doc):
self.reference_no = reference_doc.name
@@ -1026,7 +1029,7 @@ class PaymentEntry(AccountsController):
for fieldname in tax_fields:
tax.set(fieldname, 0.0)
- self.paid_amount_after_tax = self.paid_amount
+ self.paid_amount_after_tax = self.base_paid_amount
def determine_exclusive_rate(self):
if not any(cint(tax.included_in_paid_amount) for tax in self.get("taxes")):
@@ -1045,7 +1048,7 @@ class PaymentEntry(AccountsController):
cumulated_tax_fraction += tax.tax_fraction_for_current_item
- self.paid_amount_after_tax = flt(self.paid_amount / (1 + cumulated_tax_fraction))
+ self.paid_amount_after_tax = flt(self.base_paid_amount / (1 + cumulated_tax_fraction))
def calculate_taxes(self):
self.total_taxes_and_charges = 0.0
@@ -1068,7 +1071,7 @@ class PaymentEntry(AccountsController):
current_tax_amount += actual_tax_dict[tax.idx]
tax.tax_amount = current_tax_amount
- tax.base_tax_amount = tax.tax_amount * self.source_exchange_rate
+ tax.base_tax_amount = current_tax_amount
if tax.add_deduct_tax == "Deduct":
current_tax_amount *= -1.0
@@ -1082,14 +1085,20 @@ class PaymentEntry(AccountsController):
self.get("taxes")[i - 1].total + current_tax_amount, self.precision("total", tax)
)
- tax.base_total = tax.total * self.source_exchange_rate
+ tax.base_total = tax.total
if self.payment_type == "Pay":
- self.base_total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate)
- self.total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate)
- else:
- self.base_total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate)
- self.total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate)
+ if tax.currency != self.paid_to_account_currency:
+ self.total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate)
+ else:
+ self.total_taxes_and_charges += current_tax_amount
+ elif self.payment_type == "Receive":
+ if tax.currency != self.paid_from_account_currency:
+ self.total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate)
+ else:
+ self.total_taxes_and_charges += current_tax_amount
+
+ self.base_total_taxes_and_charges += tax.base_tax_amount
if self.get("taxes"):
self.paid_amount_after_tax = self.get("taxes")[-1].base_total
@@ -1184,6 +1193,8 @@ def get_outstanding_reference_documents(args):
ple = qb.DocType("Payment Ledger Entry")
common_filter = []
+ accounting_dimensions_filter = []
+ posting_and_due_date = []
# confirm that Supplier is not blocked
if args.get("party_type") == "Supplier":
@@ -1200,7 +1211,7 @@ def get_outstanding_reference_documents(args):
party_account_currency = get_account_currency(args.get("party_account"))
company_currency = frappe.get_cached_value("Company", args.get("company"), "default_currency")
- # Get positive outstanding sales /purchase invoices/ Fees
+ # Get positive outstanding sales /purchase invoices
condition = ""
if args.get("voucher_type") and args.get("voucher_no"):
condition = " and voucher_type={0} and voucher_no={1}".format(
@@ -1212,7 +1223,7 @@ def get_outstanding_reference_documents(args):
# Add cost center condition
if args.get("cost_center"):
condition += " and cost_center='%s'" % args.get("cost_center")
- common_filter.append(ple.cost_center == args.get("cost_center"))
+ accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"],
@@ -1224,7 +1235,7 @@ def get_outstanding_reference_documents(args):
condition += " and {0} between '{1}' and '{2}'".format(
fieldname, args.get(date_fields[0]), args.get(date_fields[1])
)
- common_filter.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
+ posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
if args.get("company"):
condition += " and company = {0}".format(frappe.db.escape(args.get("company")))
@@ -1235,8 +1246,10 @@ def get_outstanding_reference_documents(args):
args.get("party"),
args.get("party_account"),
common_filter=common_filter,
+ posting_date=posting_and_due_date,
min_outstanding=args.get("outstanding_amt_greater_than"),
max_outstanding=args.get("outstanding_amt_less_than"),
+ accounting_dimensions=accounting_dimensions_filter,
)
outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices)
@@ -1297,7 +1310,7 @@ def split_invoices_based_on_payment_terms(outstanding_invoices):
d.voucher_type, d.voucher_no, "payment_terms_template"
)
if payment_term_template:
- allocate_payment_based_on_payment_terms = frappe.db.get_value(
+ allocate_payment_based_on_payment_terms = frappe.get_cached_value(
"Payment Terms Template", payment_term_template, "allocate_payment_based_on_payment_terms"
)
if allocate_payment_based_on_payment_terms:
@@ -1529,7 +1542,7 @@ def get_account_details(account, date, cost_center=None):
{
"account_currency": get_account_currency(account),
"account_balance": account_balance,
- "account_type": frappe.db.get_value("Account", account, "account_type"),
+ "account_type": frappe.get_cached_value("Account", account, "account_type"),
}
)
@@ -1595,10 +1608,11 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
elif reference_doctype != "Journal Entry":
if not total_amount:
if party_account_currency == company_currency:
- total_amount = ref_doc.base_grand_total
+ # for handling cases that don't have multi-currency (base field)
+ total_amount = ref_doc.get("grand_total") or ref_doc.get("base_grand_total")
exchange_rate = 1
else:
- total_amount = ref_doc.grand_total
+ total_amount = ref_doc.get("grand_total")
if not exchange_rate:
# Get the exchange rate from the original ref doc
# or get it based on the posting date of the ref doc.
@@ -1609,7 +1623,7 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
if reference_doctype in ("Sales Invoice", "Purchase Invoice"):
outstanding_amount = ref_doc.get("outstanding_amount")
else:
- outstanding_amount = flt(total_amount) - flt(ref_doc.advance_paid)
+ outstanding_amount = flt(total_amount) - flt(ref_doc.get("advance_paid"))
else:
# Get the exchange rate based on the posting date of the ref doc.
@@ -1627,16 +1641,23 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
@frappe.whitelist()
-def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None):
+def get_payment_entry(
+ dt, dn, party_amount=None, bank_account=None, bank_amount=None, party_type=None, payment_type=None
+):
reference_doc = None
doc = frappe.get_doc(dt, dn)
- if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0:
+ if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= 99.99:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
- party_type = set_party_type(dt)
+ if not party_type:
+ party_type = set_party_type(dt)
+
party_account = set_party_account(dt, dn, doc, party_type)
party_account_currency = set_party_account_currency(dt, party_account, doc)
- payment_type = set_payment_type(dt, doc)
+
+ if not payment_type:
+ payment_type = set_payment_type(dt, doc)
+
grand_total, outstanding_amount = set_grand_total_and_outstanding_amount(
party_amount, dt, party_account_currency, doc
)
@@ -1690,9 +1711,9 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
if doc.doctype == "Purchase Invoice" and doc.invoice_is_blocked():
frappe.msgprint(_("{0} is on hold till {1}").format(doc.name, doc.release_date))
else:
- if doc.doctype in ("Sales Invoice", "Purchase Invoice") and frappe.get_value(
+ if doc.doctype in ("Sales Invoice", "Purchase Invoice") and frappe.get_cached_value(
"Payment Terms Template",
- {"name": doc.payment_terms_template},
+ doc.payment_terms_template,
"allocate_payment_based_on_payment_terms",
):
@@ -1743,6 +1764,8 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
pe.setup_party_account_field()
pe.set_missing_values()
+ update_accounting_dimensions(pe, doc)
+
if party_account and bank:
pe.set_exchange_rate(ref_doc=reference_doc)
pe.set_amounts()
@@ -1760,6 +1783,18 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
return pe
+def update_accounting_dimensions(pe, doc):
+ """
+ Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document
+ """
+ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_accounting_dimensions,
+ )
+
+ for dimension in get_accounting_dimensions():
+ pe.set(dimension, doc.get(dimension))
+
+
def get_bank_cash_account(doc, bank_account):
bank = get_default_bank_cash_account(
doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), account=bank_account
@@ -1786,8 +1821,6 @@ def set_party_account(dt, dn, doc, party_type):
party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to
elif dt == "Purchase Invoice":
party_account = doc.credit_to
- elif dt == "Fees":
- party_account = doc.receivable_account
else:
party_account = get_party_account(party_type, doc.get(party_type.lower()), doc.company)
return party_account
@@ -1803,8 +1836,7 @@ def set_party_account_currency(dt, party_account, doc):
def set_payment_type(dt, doc):
if (
- dt == "Sales Order"
- or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)
+ dt == "Sales Order" or (dt in ("Sales Invoice", "Dunning") and doc.outstanding_amount > 0)
) or (dt == "Purchase Invoice" and doc.outstanding_amount < 0):
payment_type = "Receive"
else:
@@ -1822,18 +1854,15 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
else:
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
- elif dt == "Fees":
- grand_total = doc.grand_total
- outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
else:
if party_account_currency == doc.company_currency:
- grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total)
+ grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
else:
- grand_total = flt(doc.get("rounded_total") or doc.grand_total)
- outstanding_amount = grand_total - flt(doc.advance_paid)
+ grand_total = flt(doc.get("rounded_total") or doc.get("grand_total"))
+ outstanding_amount = doc.get("outstanding_amount") or (grand_total - flt(doc.advance_paid))
return grand_total, outstanding_amount
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 02627eb007..123b5dfd51 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -4,6 +4,7 @@
import unittest
import frappe
+from frappe import qb
from frappe.tests.utils import FrappeTestCase
from frappe.utils import flt, nowdate
@@ -722,6 +723,46 @@ class TestPaymentEntry(FrappeTestCase):
flt(payment_entry.total_taxes_and_charges, 2), flt(10 / payment_entry.target_exchange_rate, 2)
)
+ def test_gl_of_multi_currency_payment_with_taxes(self):
+ payment_entry = create_payment_entry(
+ party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True
+ )
+ payment_entry.append(
+ "taxes",
+ {
+ "account_head": "_Test Account Service Tax - _TC",
+ "charge_type": "Actual",
+ "tax_amount": 100,
+ "add_deduct_tax": "Add",
+ "description": "Test",
+ },
+ )
+ payment_entry.target_exchange_rate = 80
+ payment_entry.received_amount = 12.5
+ payment_entry = payment_entry.submit()
+ gle = qb.DocType("GL Entry")
+ gl_entries = (
+ qb.from_(gle)
+ .select(
+ gle.account,
+ gle.debit,
+ gle.credit,
+ gle.debit_in_account_currency,
+ gle.credit_in_account_currency,
+ )
+ .orderby(gle.account)
+ .where(gle.voucher_no == payment_entry.name)
+ .run()
+ )
+
+ expected_gl_entries = (
+ ("_Test Account Service Tax - _TC", 100.0, 0.0, 100.0, 0.0),
+ ("_Test Bank - _TC", 0.0, 1100.0, 0.0, 1100.0),
+ ("_Test Payable USD - _TC", 1000.0, 0.0, 12.5, 0),
+ )
+
+ self.assertEqual(gl_entries, expected_gl_entries)
+
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
index 8961167f01..3003c68196 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
@@ -25,7 +25,8 @@
"in_list_view": 1,
"label": "Type",
"options": "DocType",
- "reqd": 1
+ "reqd": 1,
+ "search_index": 1
},
{
"columns": 2,
@@ -35,7 +36,8 @@
"in_list_view": 1,
"label": "Name",
"options": "reference_doctype",
- "reqd": 1
+ "reqd": 1,
+ "search_index": 1
},
{
"fieldname": "due_date",
@@ -104,7 +106,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-09-26 17:06:55.597389",
+ "modified": "2022-12-12 12:31:44.919895",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",
@@ -113,5 +115,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js
index 8f09bc3691..aff067eab8 100644
--- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js
+++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js
@@ -3,6 +3,7 @@
frappe.ui.form.on('Payment Gateway Account', {
refresh(frm) {
+ erpnext.utils.check_payments_app();
if(!frm.doc.__islocal) {
frm.set_df_property('payment_gateway', 'read_only', 1);
}
diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py
index ab47b6151c..791de2570a 100644
--- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py
+++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py
@@ -11,7 +11,7 @@ class PaymentGatewayAccount(Document):
self.name = self.payment_gateway + " - " + self.currency
def validate(self):
- self.currency = frappe.db.get_value("Account", self.payment_account, "account_currency")
+ self.currency = frappe.get_cached_value("Account", self.payment_account, "account_currency")
self.update_default_payment_gateway()
self.set_as_default_if_not_set()
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
index 4596b00fc1..22842cec0f 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
@@ -22,7 +22,8 @@
"amount",
"account_currency",
"amount_in_account_currency",
- "delinked"
+ "delinked",
+ "remarks"
],
"fields": [
{
@@ -136,12 +137,17 @@
"fieldtype": "Link",
"label": "Finance Book",
"options": "Finance Book"
+ },
+ {
+ "fieldname": "remarks",
+ "fieldtype": "Text",
+ "label": "Remarks"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-07-11 09:13:54.379168",
+ "modified": "2022-08-22 15:32:56.629430",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Ledger Entry",
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
index bcbcb670fa..58691ab8d4 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
@@ -97,7 +97,7 @@ class PaymentLedgerEntry(Document):
)
def validate_dimensions_for_pl_and_bs(self):
- account_type = frappe.db.get_value("Account", self.account, "report_type")
+ account_type = frappe.get_cached_value("Account", self.account, "report_type")
for dimension in get_checks_for_pl_and_bs_accounts():
if (
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
index a71b19e092..fc6dbba7e7 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
+++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
@@ -3,12 +3,13 @@
import frappe
from frappe import qb
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item
@@ -127,6 +128,25 @@ class TestPaymentLedgerEntry(FrappeTestCase):
payment.posting_date = posting_date
return payment
+ def create_sales_order(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ so = make_sales_order(
+ company=self.company,
+ transaction_date=posting_date,
+ customer=self.customer,
+ item_code=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ currency="INR",
+ qty=qty,
+ rate=100,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return so
+
def clear_old_entries(self):
doctype_list = [
"GL Entry",
@@ -406,3 +426,89 @@ class TestPaymentLedgerEntry(FrappeTestCase):
]
self.assertEqual(pl_entries_for_crnote[0], expected_values[0])
self.assertEqual(pl_entries_for_crnote[1], expected_values[1])
+
+ @change_settings(
+ "Accounts Settings",
+ {"unlink_payment_on_cancellation_of_invoice": 1, "delete_linked_ledger_entries": 1},
+ )
+ def test_multi_payment_unlink_on_invoice_cancellation(self):
+ transaction_date = nowdate()
+ amount = 100
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ for amt in [40, 40, 20]:
+ # payment 1
+ pe = get_payment_entry(si.doctype, si.name)
+ pe.paid_amount = amt
+ pe.get("references")[0].allocated_amount = amt
+ pe = pe.save().submit()
+
+ si.reload()
+ si.cancel()
+
+ entries = frappe.db.get_list(
+ "Payment Ledger Entry",
+ filters={"against_voucher_type": si.doctype, "against_voucher_no": si.name, "delinked": 0},
+ )
+ self.assertEqual(entries, [])
+
+ # with references removed, deletion should be possible
+ si.delete()
+ self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, si.doctype, si.name)
+
+ @change_settings(
+ "Accounts Settings",
+ {"unlink_payment_on_cancellation_of_invoice": 1, "delete_linked_ledger_entries": 1},
+ )
+ def test_multi_je_unlink_on_invoice_cancellation(self):
+ transaction_date = nowdate()
+ amount = 100
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ # multiple JE's against invoice
+ for amt in [40, 40, 20]:
+ je1 = self.create_journal_entry(
+ self.income_account, self.debit_to, amt, posting_date=transaction_date
+ )
+ je1.get("accounts")[1].party_type = "Customer"
+ je1.get("accounts")[1].party = self.customer
+ je1.get("accounts")[1].reference_type = si.doctype
+ je1.get("accounts")[1].reference_name = si.name
+ je1 = je1.save().submit()
+
+ si.reload()
+ si.cancel()
+
+ entries = frappe.db.get_list(
+ "Payment Ledger Entry",
+ filters={"against_voucher_type": si.doctype, "against_voucher_no": si.name, "delinked": 0},
+ )
+ self.assertEqual(entries, [])
+
+ # with references removed, deletion should be possible
+ si.delete()
+ self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, si.doctype, si.name)
+
+ @change_settings(
+ "Accounts Settings",
+ {"unlink_payment_on_cancellation_of_invoice": 1, "delete_linked_ledger_entries": 1},
+ )
+ def test_advance_payment_unlink_on_order_cancellation(self):
+ transaction_date = nowdate()
+ amount = 100
+ so = self.create_sales_order(qty=1, rate=amount, posting_date=transaction_date).save().submit()
+
+ pe = get_payment_entry(so.doctype, so.name).save().submit()
+
+ so.reload()
+ so.cancel()
+
+ entries = frappe.db.get_list(
+ "Payment Ledger Entry",
+ filters={"against_voucher_type": so.doctype, "against_voucher_no": so.name, "delinked": 0},
+ )
+ self.assertEqual(entries, [])
+
+ # with references removed, deletion should be possible
+ so.delete()
+ self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js
index 0b334ae076..d986f32066 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js
@@ -170,7 +170,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
}
reconcile() {
- var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount && !d.difference_account);
+ var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount);
if (show_dialog && show_dialog.length) {
@@ -179,8 +179,12 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
title: __("Select Difference Account"),
fields: [
{
- fieldname: "allocation", fieldtype: "Table", label: __("Allocation"),
- data: this.data, in_place_edit: true,
+ fieldname: "allocation",
+ fieldtype: "Table",
+ label: __("Allocation"),
+ data: this.data,
+ in_place_edit: true,
+ cannot_add_rows: true,
get_data: () => {
return this.data;
},
@@ -218,6 +222,10 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
read_only: 1
}]
},
+ {
+ fieldtype: 'HTML',
+ options: " New Journal Entry will be posted for the difference amount "
+ }
],
primary_action: () => {
const args = dialog.get_values()["allocation"];
@@ -234,7 +242,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
});
this.frm.doc.allocation.forEach(d => {
- if (d.difference_amount && !d.difference_account) {
+ if (d.difference_amount) {
dialog.fields_dict.allocation.df.data.push({
'docname': d.name,
'reference_name': d.reference_name,
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 5ed34d34a3..e3d9c26b2d 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -22,6 +22,8 @@ class PaymentReconciliation(Document):
def __init__(self, *args, **kwargs):
super(PaymentReconciliation, self).__init__(*args, **kwargs)
self.common_filter_conditions = []
+ self.accounting_dimension_filter_conditions = []
+ self.ple_posting_date_filter = []
@frappe.whitelist()
def get_unreconciled_entries(self):
@@ -67,6 +69,10 @@ class PaymentReconciliation(Document):
def get_jv_entries(self):
condition = self.get_conditions()
+
+ if self.get("cost_center"):
+ condition += f" and t2.cost_center = '{self.cost_center}' "
+
dr_or_cr = (
"credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
@@ -77,12 +83,13 @@ class PaymentReconciliation(Document):
"t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1"
)
+ # nosemgrep
journal_entries = frappe.db.sql(
"""
select
"Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row,
- {dr_or_cr} as amount, t2.is_advance,
+ {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
t2.account_currency as currency
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
@@ -150,6 +157,7 @@ class PaymentReconciliation(Document):
return_outstanding = ple_query.get_voucher_outstandings(
vouchers=return_invoices,
common_filter=self.common_filter_conditions,
+ posting_date=self.ple_posting_date_filter,
min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None,
get_payments=True,
@@ -187,8 +195,10 @@ class PaymentReconciliation(Document):
self.party,
self.receivable_payable_account,
common_filter=self.common_filter_conditions,
+ posting_date=self.ple_posting_date_filter,
min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None,
max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None,
+ accounting_dimensions=self.accounting_dimension_filter_conditions,
)
if self.invoice_limit:
@@ -209,9 +219,26 @@ class PaymentReconciliation(Document):
inv.currency = entry.get("currency")
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
+ def get_difference_amount(self, payment_entry, invoice, allocated_amount):
+ difference_amount = 0
+ if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
+ "exchange_rate", 1
+ ):
+ allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount
+ allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount
+ difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
+
+ return difference_amount
+
@frappe.whitelist()
def allocate_entries(self, args):
self.validate_entries()
+
+ invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
+ default_exchange_gain_loss_account = frappe.get_cached_value(
+ "Company", self.company, "exchange_gain_loss_account"
+ )
+
entries = []
for pay in args.get("payments"):
pay.update({"unreconciled_amount": pay.get("amount")})
@@ -224,12 +251,22 @@ class PaymentReconciliation(Document):
res = self.get_allocated_entry(pay, inv, pay["amount"])
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
pay["amount"] = 0
+
+ inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))
+ if pay.get("reference_type") in ["Sales Invoice", "Purchase Invoice"]:
+ pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
+
+ res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
+ res.difference_account = default_exchange_gain_loss_account
+ res.exchange_rate = inv.get("exchange_rate")
+
if pay.get("amount") == 0:
entries.append(res)
break
elif inv.get("outstanding_amount") == 0:
entries.append(res)
continue
+
else:
break
@@ -251,6 +288,7 @@ class PaymentReconciliation(Document):
"amount": pay.get("amount"),
"allocated_amount": allocated_amount,
"difference_amount": pay.get("difference_amount"),
+ "currency": inv.get("currency"),
}
)
@@ -273,7 +311,11 @@ class PaymentReconciliation(Document):
else:
reconciled_entry = entry_list
- reconciled_entry.append(self.get_payment_details(row, dr_or_cr))
+ payment_details = self.get_payment_details(row, dr_or_cr)
+ reconciled_entry.append(payment_details)
+
+ if payment_details.difference_amount:
+ self.make_difference_entry(payment_details)
if entry_list:
reconcile_against_document(entry_list)
@@ -284,6 +326,57 @@ class PaymentReconciliation(Document):
msgprint(_("Successfully Reconciled"))
self.get_unreconciled_entries()
+ def make_difference_entry(self, row):
+ journal_entry = frappe.new_doc("Journal Entry")
+ journal_entry.voucher_type = "Exchange Gain Or Loss"
+ journal_entry.company = self.company
+ journal_entry.posting_date = nowdate()
+ journal_entry.multi_currency = 1
+
+ party_account_currency = frappe.get_cached_value(
+ "Account", self.receivable_payable_account, "account_currency"
+ )
+ difference_account_currency = frappe.get_cached_value(
+ "Account", row.difference_account, "account_currency"
+ )
+
+ # Account Currency has balance
+ dr_or_cr = "debit" if self.party_type == "Customer" else "credit"
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ journal_account = frappe._dict(
+ {
+ "account": self.receivable_payable_account,
+ "party_type": self.party_type,
+ "party": self.party,
+ "account_currency": party_account_currency,
+ "exchange_rate": 0,
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ "reference_type": row.against_voucher_type,
+ "reference_name": row.against_voucher,
+ dr_or_cr: flt(row.difference_amount),
+ dr_or_cr + "_in_account_currency": 0,
+ }
+ )
+
+ journal_entry.append("accounts", journal_account)
+
+ journal_account = frappe._dict(
+ {
+ "account": row.difference_account,
+ "account_currency": difference_account_currency,
+ "exchange_rate": 1,
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ reverse_dr_or_cr + "_in_account_currency": flt(row.difference_amount),
+ reverse_dr_or_cr: flt(row.difference_amount),
+ }
+ )
+
+ journal_entry.append("accounts", journal_account)
+
+ journal_entry.save()
+ journal_entry.submit()
+
def get_payment_details(self, row, dr_or_cr):
return frappe._dict(
{
@@ -293,6 +386,7 @@ class PaymentReconciliation(Document):
"against_voucher_type": row.get("invoice_type"),
"against_voucher": row.get("invoice_number"),
"account": self.receivable_payable_account,
+ "exchange_rate": row.get("exchange_rate"),
"party_type": self.party_type,
"party": self.party,
"is_advance": row.get("is_advance"),
@@ -317,6 +411,49 @@ class PaymentReconciliation(Document):
if not self.get("payments"):
frappe.throw(_("No records found in the Payments table"))
+ def get_invoice_exchange_map(self, invoices, payments):
+ sales_invoices = [
+ d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Sales Invoice"
+ ]
+
+ sales_invoices.extend(
+ [d.get("reference_name") for d in payments if d.get("reference_type") == "Sales Invoice"]
+ )
+ purchase_invoices = [
+ d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Purchase Invoice"
+ ]
+ purchase_invoices.extend(
+ [d.get("reference_name") for d in payments if d.get("reference_type") == "Purchase Invoice"]
+ )
+
+ invoice_exchange_map = frappe._dict()
+
+ if sales_invoices:
+ sales_invoice_map = frappe._dict(
+ frappe.db.get_all(
+ "Sales Invoice",
+ filters={"name": ("in", sales_invoices)},
+ fields=["name", "conversion_rate"],
+ as_list=1,
+ )
+ )
+
+ invoice_exchange_map.update(sales_invoice_map)
+
+ if purchase_invoices:
+ purchase_invoice_map = frappe._dict(
+ frappe.db.get_all(
+ "Purchase Invoice",
+ filters={"name": ("in", purchase_invoices)},
+ fields=["name", "conversion_rate"],
+ as_list=1,
+ )
+ )
+
+ invoice_exchange_map.update(purchase_invoice_map)
+
+ return invoice_exchange_map
+
def validate_allocation(self):
unreconciled_invoices = frappe._dict()
@@ -350,24 +487,26 @@ class PaymentReconciliation(Document):
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear()
+ self.accounting_dimension_filter_conditions.clear()
+ self.ple_posting_date_filter.clear()
ple = qb.DocType("Payment Ledger Entry")
self.common_filter_conditions.append(ple.company == self.company)
if self.get("cost_center") and (get_invoices or get_return_invoices):
- self.common_filter_conditions.append(ple.cost_center == self.cost_center)
+ self.accounting_dimension_filter_conditions.append(ple.cost_center == self.cost_center)
if get_invoices:
if self.from_invoice_date:
- self.common_filter_conditions.append(ple.posting_date.gte(self.from_invoice_date))
+ self.ple_posting_date_filter.append(ple.posting_date.gte(self.from_invoice_date))
if self.to_invoice_date:
- self.common_filter_conditions.append(ple.posting_date.lte(self.to_invoice_date))
+ self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_invoice_date))
elif get_return_invoices:
if self.from_payment_date:
- self.common_filter_conditions.append(ple.posting_date.gte(self.from_payment_date))
+ self.ple_posting_date_filter.append(ple.posting_date.gte(self.from_payment_date))
if self.to_payment_date:
- self.common_filter_conditions.append(ple.posting_date.lte(self.to_payment_date))
+ self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date))
def get_conditions(self, get_payments=False):
condition = " and company = '{0}' ".format(self.company)
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 625382a3e9..f9dda0593b 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -6,8 +6,10 @@ import unittest
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase
-from frappe.utils import add_days, nowdate
+from frappe.utils import add_days, flt, nowdate
+from erpnext import get_default_cost_center
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
@@ -20,6 +22,7 @@ class TestPaymentReconciliation(FrappeTestCase):
self.create_item()
self.create_customer()
self.create_account()
+ self.create_cost_center()
self.clear_old_entries()
def tearDown(self):
@@ -72,33 +75,11 @@ class TestPaymentReconciliation(FrappeTestCase):
self.item = item if isinstance(item, str) else item.item_code
def create_customer(self):
- if frappe.db.exists("Customer", "_Test PR Customer"):
- self.customer = "_Test PR Customer"
- else:
- customer = frappe.new_doc("Customer")
- customer.customer_name = "_Test PR Customer"
- customer.type = "Individual"
- customer.save()
- self.customer = customer.name
-
- if frappe.db.exists("Customer", "_Test PR Customer 2"):
- self.customer2 = "_Test PR Customer 2"
- else:
- customer = frappe.new_doc("Customer")
- customer.customer_name = "_Test PR Customer 2"
- customer.type = "Individual"
- customer.save()
- self.customer2 = customer.name
-
- if frappe.db.exists("Customer", "_Test PR Customer 3"):
- self.customer3 = "_Test PR Customer 3"
- else:
- customer = frappe.new_doc("Customer")
- customer.customer_name = "_Test PR Customer 3"
- customer.type = "Individual"
- customer.default_currency = "EUR"
- customer.save()
- self.customer3 = customer.name
+ self.customer = make_customer("_Test PR Customer")
+ self.customer2 = make_customer("_Test PR Customer 2")
+ self.customer3 = make_customer("_Test PR Customer 3", "EUR")
+ self.customer4 = make_customer("_Test PR Customer 4", "EUR")
+ self.customer5 = make_customer("_Test PR Customer 5", "EUR")
def create_account(self):
account_name = "Debtors EUR"
@@ -216,6 +197,22 @@ class TestPaymentReconciliation(FrappeTestCase):
)
return je
+ def create_cost_center(self):
+ # Setup cost center
+ cc_name = "Sub"
+
+ self.main_cc = frappe.get_doc("Cost Center", get_default_cost_center(self.company))
+
+ cc_exists = frappe.db.get_list("Cost Center", filters={"cost_center_name": cc_name})
+ if cc_exists:
+ self.sub_cc = frappe.get_doc("Cost Center", cc_exists[0].name)
+ else:
+ sub_cc = frappe.new_doc("Cost Center")
+ sub_cc.cost_center_name = "Sub"
+ sub_cc.parent_cost_center = self.main_cc.parent_cost_center
+ sub_cc.company = self.main_cc.company
+ self.sub_cc = sub_cc.save()
+
def test_filter_min_max(self):
# check filter condition minimum and maximum amount
self.create_sales_invoice(qty=1, rate=300)
@@ -283,6 +280,41 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.get("invoices")), 2)
self.assertEqual(len(pr.get("payments")), 2)
+ def test_filter_posting_date_case2(self):
+ """
+ Posting date should not affect outstanding amount calculation
+ """
+
+ from_date = add_days(nowdate(), -30)
+ to_date = nowdate()
+ self.create_payment_entry(amount=25, posting_date=from_date).submit()
+ self.create_sales_invoice(rate=25, qty=1, posting_date=to_date)
+
+ pr = self.create_payment_reconciliation()
+ pr.from_invoice_date = pr.from_payment_date = from_date
+ pr.to_invoice_date = pr.to_payment_date = to_date
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ pr.from_invoice_date = pr.from_payment_date = to_date
+ pr.to_invoice_date = pr.to_payment_date = to_date
+
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 0)
+
def test_filter_invoice_limit(self):
# check filter condition - invoice limit
transaction_date = nowdate()
@@ -441,6 +473,11 @@ class TestPaymentReconciliation(FrappeTestCase):
invoices = [x.as_dict() for x in pr.get("invoices")]
payments = [x.as_dict() for x in pr.get("payments")]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Cr Note and Invoice are of the same currency. There shouldn't any difference amount.
+ for row in pr.allocation:
+ self.assertEqual(flt(row.get("difference_amount")), 0.0)
+
pr.reconcile()
pr.get_unreconciled_entries()
@@ -474,6 +511,11 @@ class TestPaymentReconciliation(FrappeTestCase):
payments = [x.as_dict() for x in pr.get("payments")]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.allocation[0].allocated_amount = allocated_amount
+
+ # Cr Note and Invoice are of the same currency. There shouldn't any difference amount.
+ for row in pr.allocation:
+ self.assertEqual(flt(row.get("difference_amount")), 0.0)
+
pr.reconcile()
# assert outstanding
@@ -543,3 +585,255 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.payments), 1)
self.assertEqual(pr.payments[0].amount, amount)
self.assertEqual(pr.payments[0].currency, "EUR")
+
+ def test_difference_amount_via_journal_entry(self):
+ # Make Sale Invoice
+ si = self.create_sales_invoice(
+ qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
+ )
+ si.customer = self.customer4
+ si.currency = "EUR"
+ si.conversion_rate = 85
+ si.debit_to = self.debtors_eur
+ si.save().submit()
+
+ # Make payment using Journal Entry
+ je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate())
+ je1.multi_currency = 1
+ je1.accounts[0].exchange_rate = 1
+ je1.accounts[0].credit_in_account_currency = 0
+ je1.accounts[0].credit = 0
+ je1.accounts[0].debit_in_account_currency = 8000
+ je1.accounts[0].debit = 8000
+ je1.accounts[1].party_type = "Customer"
+ je1.accounts[1].party = self.customer4
+ je1.accounts[1].exchange_rate = 80
+ je1.accounts[1].credit_in_account_currency = 100
+ je1.accounts[1].credit = 8000
+ je1.accounts[1].debit_in_account_currency = 0
+ je1.accounts[1].debit = 0
+ je1.save()
+ je1.submit()
+
+ je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate())
+ je2.multi_currency = 1
+ je2.accounts[0].exchange_rate = 1
+ je2.accounts[0].credit_in_account_currency = 0
+ je2.accounts[0].credit = 0
+ je2.accounts[0].debit_in_account_currency = 16000
+ je2.accounts[0].debit = 16000
+ je2.accounts[1].party_type = "Customer"
+ je2.accounts[1].party = self.customer4
+ je2.accounts[1].exchange_rate = 80
+ je2.accounts[1].credit_in_account_currency = 200
+ je1.accounts[1].credit = 16000
+ je1.accounts[1].debit_in_account_currency = 0
+ je1.accounts[1].debit = 0
+ je2.save()
+ je2.submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.party = self.customer4
+ pr.receivable_payable_account = self.debtors_eur
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 2)
+
+ # Test exact payment allocation
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[0].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ # Test partial payment allocation (with excess payment entry)
+ pr.set("allocation", [])
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[1].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR"
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ # Check if difference journal entry gets generated for difference amount after reconciliation
+ pr.reconcile()
+ total_debit_amount = frappe.db.get_all(
+ "Journal Entry Account",
+ {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
+ "sum(debit) as amount",
+ group_by="reference_name",
+ )[0].amount
+
+ self.assertEqual(flt(total_debit_amount, 2), -500)
+
+ def test_difference_amount_via_payment_entry(self):
+ # Make Sale Invoice
+ si = self.create_sales_invoice(
+ qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
+ )
+ si.customer = self.customer5
+ si.currency = "EUR"
+ si.conversion_rate = 85
+ si.debit_to = self.debtors_eur
+ si.save().submit()
+
+ # Make payment using Payment Entry
+ pe1 = create_payment_entry(
+ company=self.company,
+ payment_type="Receive",
+ party_type="Customer",
+ party=self.customer5,
+ paid_from=self.debtors_eur,
+ paid_to=self.bank,
+ paid_amount=100,
+ )
+
+ pe1.source_exchange_rate = 80
+ pe1.received_amount = 8000
+ pe1.save()
+ pe1.submit()
+
+ pe2 = create_payment_entry(
+ company=self.company,
+ payment_type="Receive",
+ party_type="Customer",
+ party=self.customer5,
+ paid_from=self.debtors_eur,
+ paid_to=self.bank,
+ paid_amount=200,
+ )
+
+ pe2.source_exchange_rate = 80
+ pe2.received_amount = 16000
+ pe2.save()
+ pe2.submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.party = self.customer5
+ pr.receivable_payable_account = self.debtors_eur
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 2)
+
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[0].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ pr.set("allocation", [])
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[1].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ def test_differing_cost_center_on_invoice_and_payment(self):
+ """
+ Cost Center filter should not affect outstanding amount calculation
+ """
+
+ si = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
+ si.cost_center = self.main_cc.name
+ si.submit()
+ pr = get_payment_entry(si.doctype, si.name)
+ pr.cost_center = self.sub_cc.name
+ pr = pr.save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.cost_center = self.main_cc.name
+
+ pr.get_unreconciled_entries()
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 0)
+ self.assertEqual(len(pr.get("payments")), 0)
+
+ def test_cost_center_filter_on_vouchers(self):
+ """
+ Test Cost Center filter is applied on Invoices, Payment Entries and Journals
+ """
+ transaction_date = nowdate()
+ rate = 100
+
+ # 'Main - PR' Cost Center
+ si1 = self.create_sales_invoice(
+ qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
+ )
+ si1.cost_center = self.main_cc.name
+ si1.submit()
+
+ pe1 = self.create_payment_entry(posting_date=transaction_date, amount=rate)
+ pe1.cost_center = self.main_cc.name
+ pe1 = pe1.save().submit()
+
+ je1 = self.create_journal_entry(self.bank, self.debit_to, 100, transaction_date)
+ je1.accounts[0].cost_center = self.main_cc.name
+ je1.accounts[1].cost_center = self.main_cc.name
+ je1.accounts[1].party_type = "Customer"
+ je1.accounts[1].party = self.customer
+ je1 = je1.save().submit()
+
+ # 'Sub - PR' Cost Center
+ si2 = self.create_sales_invoice(
+ qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
+ )
+ si2.cost_center = self.sub_cc.name
+ si2.submit()
+
+ pe2 = self.create_payment_entry(posting_date=transaction_date, amount=rate)
+ pe2.cost_center = self.sub_cc.name
+ pe2 = pe2.save().submit()
+
+ je2 = self.create_journal_entry(self.bank, self.debit_to, 100, transaction_date)
+ je2.accounts[0].cost_center = self.sub_cc.name
+ je2.accounts[1].cost_center = self.sub_cc.name
+ je2.accounts[1].party_type = "Customer"
+ je2.accounts[1].party = self.customer
+ je2 = je2.save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.cost_center = self.main_cc.name
+
+ pr.get_unreconciled_entries()
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(pr.get("invoices")[0].get("invoice_number"), si1.name)
+ self.assertEqual(len(pr.get("payments")), 2)
+ payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
+ self.assertCountEqual(payment_vouchers, [pe1.name, je1.name])
+
+ # Change cost center
+ pr.cost_center = self.sub_cc.name
+
+ pr.get_unreconciled_entries()
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(pr.get("invoices")[0].get("invoice_number"), si2.name)
+ self.assertEqual(len(pr.get("payments")), 2)
+ payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
+ self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
+
+
+def make_customer(customer_name, currency=None):
+ if not frappe.db.exists("Customer", customer_name):
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = customer_name
+ customer.type = "Individual"
+
+ if currency:
+ customer.default_currency = currency
+ customer.save()
+ return customer.name
+ else:
+ return customer_name
diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
index 6a21692c6a..0f7e47acfe 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
@@ -20,7 +20,9 @@
"section_break_5",
"difference_amount",
"column_break_7",
- "difference_account"
+ "difference_account",
+ "exchange_rate",
+ "currency"
],
"fields": [
{
@@ -37,7 +39,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated Amount",
- "options": "Currency",
+ "options": "currency",
"reqd": 1
},
{
@@ -112,7 +114,7 @@
"fieldtype": "Currency",
"hidden": 1,
"label": "Unreconciled Amount",
- "options": "Currency",
+ "options": "currency",
"read_only": 1
},
{
@@ -120,7 +122,7 @@
"fieldtype": "Currency",
"hidden": 1,
"label": "Amount",
- "options": "Currency",
+ "options": "currency",
"read_only": 1
},
{
@@ -129,11 +131,24 @@
"hidden": 1,
"label": "Reference Row",
"read_only": 1
+ },
+ {
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "fieldname": "exchange_rate",
+ "fieldtype": "Float",
+ "label": "Exchange Rate",
+ "read_only": 1
}
],
"istable": 1,
"links": [],
- "modified": "2021-10-06 11:48:59.616562",
+ "modified": "2022-12-24 21:01:14.882747",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Allocation",
@@ -141,5 +156,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json
index 00c9e1240c..c4dbd7e844 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json
@@ -11,7 +11,8 @@
"col_break1",
"amount",
"outstanding_amount",
- "currency"
+ "currency",
+ "exchange_rate"
],
"fields": [
{
@@ -62,11 +63,17 @@
"hidden": 1,
"label": "Currency",
"options": "Currency"
+ },
+ {
+ "fieldname": "exchange_rate",
+ "fieldtype": "Float",
+ "hidden": 1,
+ "label": "Exchange Rate"
}
],
"istable": 1,
"links": [],
- "modified": "2021-08-24 22:42:40.923179",
+ "modified": "2022-11-08 18:18:02.502149",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Invoice",
@@ -75,5 +82,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
index add07e870d..d300ea97ab 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
@@ -15,7 +15,8 @@
"difference_amount",
"sec_break1",
"remark",
- "currency"
+ "currency",
+ "exchange_rate"
],
"fields": [
{
@@ -91,11 +92,17 @@
"label": "Difference Amount",
"options": "currency",
"read_only": 1
+ },
+ {
+ "fieldname": "exchange_rate",
+ "fieldtype": "Float",
+ "hidden": 1,
+ "label": "Exchange Rate"
}
],
"istable": 1,
"links": [],
- "modified": "2021-08-30 10:51:48.140062",
+ "modified": "2022-11-08 18:18:36.268760",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Payment",
@@ -103,5 +110,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js
index 901ef1987b..e913912028 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request.js
@@ -42,7 +42,7 @@ frappe.ui.form.on("Payment Request", "refresh", function(frm) {
});
}
- if(!frm.doc.payment_gateway_account && frm.doc.status == "Initiated") {
+ if((!frm.doc.payment_gateway_account || frm.doc.payment_request_type == "Outward") && frm.doc.status == "Initiated") {
frm.add_custom_button(__('Create Payment Entry'), function(){
frappe.call({
method: "erpnext.accounts.doctype.payment_request.payment_request.make_payment_entry",
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json
index 2ee356aaf4..381f3fb531 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.json
+++ b/erpnext/accounts/doctype/payment_request/payment_request.json
@@ -32,6 +32,10 @@
"iban",
"branch_code",
"swift_number",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break",
+ "project",
"recipient_and_message",
"print_format",
"email_to",
@@ -186,8 +190,10 @@
{
"fetch_from": "bank_account.bank",
"fieldname": "bank",
- "fieldtype": "Read Only",
- "label": "Bank"
+ "fieldtype": "Link",
+ "label": "Bank",
+ "options": "Bank",
+ "read_only": 1
},
{
"fetch_from": "bank_account.bank_account_no",
@@ -360,16 +366,39 @@
"label": "Payment Channel",
"options": "\nEmail\nPhone",
"read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2020-09-18 12:24:14.178853",
+ "modified": "2022-12-21 16:56:40.115737",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -401,5 +430,6 @@
}
],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 29c497854c..2f43914c45 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -9,8 +9,10 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, get_url, nowdate
from frappe.utils.background_jobs import enqueue
-from payments.utils import get_payment_gateway_controller
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_accounting_dimensions,
+)
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_company_defaults,
get_payment_entry,
@@ -19,6 +21,14 @@ from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_pla
from erpnext.accounts.party import get_party_account, get_party_bank_account
from erpnext.accounts.utils import get_account_currency
from erpnext.erpnext_integrations.stripe_integration import create_stripe_subscription
+from erpnext.utilities import payment_app_import_guard
+
+
+def _get_payment_gateway_controller(*args, **kwargs):
+ with payment_app_import_guard():
+ from payments.utils import get_payment_gateway_controller
+
+ return get_payment_gateway_controller(*args, **kwargs)
class PaymentRequest(Document):
@@ -35,25 +45,24 @@ class PaymentRequest(Document):
frappe.throw(_("To create a Payment Request reference document is required"))
def validate_payment_request_amount(self):
- existing_payment_request_amount = get_existing_payment_request_amount(
- self.reference_doctype, self.reference_name
+ existing_payment_request_amount = flt(
+ get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
)
- if existing_payment_request_amount:
- ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
- if hasattr(ref_doc, "order_type") and getattr(ref_doc, "order_type") != "Shopping Cart":
- ref_amount = get_amount(ref_doc, self.payment_account)
+ ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
+ if not hasattr(ref_doc, "order_type") or getattr(ref_doc, "order_type") != "Shopping Cart":
+ ref_amount = get_amount(ref_doc, self.payment_account)
- if existing_payment_request_amount + flt(self.grand_total) > ref_amount:
- frappe.throw(
- _("Total Payment Request amount cannot be greater than {0} amount").format(
- self.reference_doctype
- )
+ if existing_payment_request_amount + flt(self.grand_total) > ref_amount:
+ frappe.throw(
+ _("Total Payment Request amount cannot be greater than {0} amount").format(
+ self.reference_doctype
)
+ )
def validate_currency(self):
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
- if self.payment_account and ref_doc.currency != frappe.db.get_value(
+ if self.payment_account and ref_doc.currency != frappe.get_cached_value(
"Account", self.payment_account, "account_currency"
):
frappe.throw(_("Transaction currency must be same as Payment Gateway currency"))
@@ -107,7 +116,7 @@ class PaymentRequest(Document):
self.request_phone_payment()
def request_phone_payment(self):
- controller = get_payment_gateway_controller(self.payment_gateway)
+ controller = _get_payment_gateway_controller(self.payment_gateway)
request_amount = self.get_request_amount()
payment_record = dict(
@@ -156,7 +165,7 @@ class PaymentRequest(Document):
def payment_gateway_validation(self):
try:
- controller = get_payment_gateway_controller(self.payment_gateway)
+ controller = _get_payment_gateway_controller(self.payment_gateway)
if hasattr(controller, "on_payment_request_submission"):
return controller.on_payment_request_submission(self)
else:
@@ -189,7 +198,7 @@ class PaymentRequest(Document):
)
data.update({"company": frappe.defaults.get_defaults().company})
- controller = get_payment_gateway_controller(self.payment_gateway)
+ controller = _get_payment_gateway_controller(self.payment_gateway)
controller.validate_transaction_currency(self.currency)
if hasattr(controller, "validate_minimum_transaction_amount"):
@@ -254,6 +263,7 @@ class PaymentRequest(Document):
payment_entry.update(
{
+ "mode_of_payment": self.mode_of_payment,
"reference_no": self.name,
"reference_date": nowdate(),
"remarks": "Payment Entry against {0} {1} via Payment Request {2}".format(
@@ -262,6 +272,17 @@ class PaymentRequest(Document):
}
)
+ # Update dimensions
+ payment_entry.update(
+ {
+ "cost_center": self.get("cost_center"),
+ "project": self.get("project"),
+ }
+ )
+
+ for dimension in get_accounting_dimensions():
+ payment_entry.update({dimension: self.get(dimension)})
+
if payment_entry.difference_amount:
company_details = get_company_defaults(ref_doc.company)
@@ -403,25 +424,22 @@ def make_payment_request(**args):
else ""
)
- existing_payment_request = None
- if args.order_type == "Shopping Cart":
- existing_payment_request = frappe.db.get_value(
- "Payment Request",
- {"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": ("!=", 2)},
- )
+ draft_payment_request = frappe.db.get_value(
+ "Payment Request",
+ {"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": 0},
+ )
- if existing_payment_request:
+ existing_payment_request_amount = get_existing_payment_request_amount(args.dt, args.dn)
+
+ if existing_payment_request_amount:
+ grand_total -= existing_payment_request_amount
+
+ if draft_payment_request:
frappe.db.set_value(
- "Payment Request", existing_payment_request, "grand_total", grand_total, update_modified=False
+ "Payment Request", draft_payment_request, "grand_total", grand_total, update_modified=False
)
- pr = frappe.get_doc("Payment Request", existing_payment_request)
+ pr = frappe.get_doc("Payment Request", draft_payment_request)
else:
- if args.order_type != "Shopping Cart":
- existing_payment_request_amount = get_existing_payment_request_amount(args.dt, args.dn)
-
- if existing_payment_request_amount:
- grand_total -= existing_payment_request_amount
-
pr = frappe.new_doc("Payment Request")
pr.update(
{
@@ -444,6 +462,17 @@ def make_payment_request(**args):
}
)
+ # Update dimensions
+ pr.update(
+ {
+ "cost_center": ref_doc.get("cost_center"),
+ "project": ref_doc.get("project"),
+ }
+ )
+
+ for dimension in get_accounting_dimensions():
+ pr.update({dimension: ref_doc.get(dimension)})
+
if args.order_type == "Shopping Cart" or args.mute_email:
pr.flags.mute_email = True
@@ -466,7 +495,7 @@ def get_amount(ref_doc, payment_account=None):
"""get amount based on doctype"""
dt = ref_doc.doctype
if dt in ["Sales Order", "Purchase Order"]:
- grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
+ grand_total = flt(ref_doc.rounded_total) - flt(ref_doc.advance_paid)
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if ref_doc.party_account_currency == ref_doc.currency:
diff --git a/erpnext/accounts/doctype/payment_schedule/payment_schedule.json b/erpnext/accounts/doctype/payment_schedule/payment_schedule.json
index 6ed7a3154e..dde9980ce5 100644
--- a/erpnext/accounts/doctype/payment_schedule/payment_schedule.json
+++ b/erpnext/accounts/doctype/payment_schedule/payment_schedule.json
@@ -39,6 +39,7 @@
{
"columns": 2,
"fetch_from": "payment_term.description",
+ "fetch_if_empty": 1,
"fieldname": "description",
"fieldtype": "Small Text",
"in_list_view": 1,
@@ -159,7 +160,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-04-28 05:41:35.084233",
+ "modified": "2022-09-16 13:57:06.382859",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Schedule",
@@ -168,5 +169,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 866a94d04b..ca98bee5c1 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -43,7 +43,7 @@ class PeriodClosingVoucher(AccountsController):
make_reverse_gl_entries(voucher_type="Period Closing Voucher", voucher_no=self.name)
def validate_account_head(self):
- closing_account_type = frappe.db.get_value("Account", self.closing_account_head, "root_type")
+ closing_account_type = frappe.get_cached_value("Account", self.closing_account_head, "root_type")
if closing_account_type not in ["Liability", "Equity"]:
frappe.throw(
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
index 98f3420d87..e6d9fe2b54 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
@@ -25,7 +25,7 @@ frappe.ui.form.on('POS Closing Entry', {
frappe.realtime.on('closing_process_complete', async function(data) {
await frm.reload_doc();
- if (frm.doc.status == 'Failed' && frm.doc.error_message && data.user == frappe.session.user) {
+ if (frm.doc.status == 'Failed' && frm.doc.error_message) {
frappe.msgprint({
title: __('POS Closing Failed'),
message: frm.doc.error_message,
@@ -36,6 +36,15 @@ frappe.ui.form.on('POS Closing Entry', {
});
set_html_data(frm);
+
+ if (frm.doc.docstatus == 1) {
+ if (!frm.doc.posting_date) {
+ frm.set_value("posting_date", frappe.datetime.nowdate());
+ }
+ if (!frm.doc.posting_time) {
+ frm.set_value("posting_time", frappe.datetime.now_time());
+ }
+ }
},
refresh: function(frm) {
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json
index d6e35c6a50..9d15e6cf35 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json
@@ -11,6 +11,7 @@
"period_end_date",
"column_break_3",
"posting_date",
+ "posting_time",
"pos_opening_entry",
"status",
"section_break_5",
@@ -51,7 +52,6 @@
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Period End Date",
- "read_only": 1,
"reqd": 1
},
{
@@ -219,6 +219,13 @@
"fieldtype": "Small Text",
"label": "Error",
"read_only": 1
+ },
+ {
+ "fieldname": "posting_time",
+ "fieldtype": "Time",
+ "label": "Posting Time",
+ "no_copy": 1,
+ "reqd": 1
}
],
"is_submittable": 1,
@@ -228,10 +235,11 @@
"link_fieldname": "pos_closing_entry"
}
],
- "modified": "2021-10-20 16:19:25.340565",
+ "modified": "2022-08-01 11:37:14.991228",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Closing Entry",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -278,5 +286,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
index 49aab0d0bb..115b415eed 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
@@ -15,11 +15,30 @@ from erpnext.controllers.status_updater import StatusUpdater
class POSClosingEntry(StatusUpdater):
def validate(self):
+ self.posting_date = self.posting_date or frappe.utils.nowdate()
+ self.posting_time = self.posting_time or frappe.utils.nowtime()
+
if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open":
frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry"))
+ self.validate_duplicate_pos_invoices()
self.validate_pos_invoices()
+ def validate_duplicate_pos_invoices(self):
+ pos_occurences = {}
+ for idx, inv in enumerate(self.pos_transactions, 1):
+ pos_occurences.setdefault(inv.pos_invoice, []).append(idx)
+
+ error_list = []
+ for key, value in pos_occurences.items():
+ if len(value) > 1:
+ error_list.append(
+ _("{} is added multiple times on rows: {}".format(frappe.bold(key), frappe.bold(value)))
+ )
+
+ if error_list:
+ frappe.throw(error_list, title=_("Duplicate POS Invoices found"), as_list=True)
+
def validate_pos_invoices(self):
invalid_rows = []
for d in self.pos_transactions:
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
index 15c292211c..56b857992c 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
@@ -5,6 +5,8 @@
frappe.provide("erpnext.accounts");
erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnext.selling.SellingController {
+ settings = {};
+
setup(doc) {
this.setup_posting_date_time_check();
super.setup(doc);
@@ -12,21 +14,37 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
company() {
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
+ this.frm.set_value("set_warehouse", "");
+ this.frm.set_value("taxes_and_charges", "");
}
onload(doc) {
super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice Merge Log', 'POS Closing Entry'];
+
if(doc.__islocal && doc.is_pos && frappe.get_route_str() !== 'point-of-sale') {
this.frm.script_manager.trigger("is_pos");
this.frm.refresh_fields();
}
+ this.frm.set_query("set_warehouse", function(doc) {
+ return {
+ filters: {
+ company: doc.company ? doc.company : '',
+ }
+ }
+ });
+
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
+ onload_post_render(frm) {
+ this.pos_profile(frm);
+ }
+
refresh(doc) {
super.refresh();
+
if (doc.docstatus == 1 && !doc.is_return) {
this.frm.add_custom_button(__('Return'), this.make_sales_return, __('Create'));
this.frm.page.set_inner_btn_group_as_primary(__('Create'));
@@ -36,6 +54,18 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
this.frm.return_print_format = "Sales Invoice Return";
this.frm.set_value('consolidated_invoice', '');
}
+
+ this.frm.set_query("customer", (function () {
+ const customer_groups = this.settings?.customer_groups;
+
+ if (!customer_groups?.length) return {};
+
+ return {
+ filters: {
+ customer_group: ["in", customer_groups],
+ }
+ }
+ }).bind(this));
}
is_pos() {
@@ -88,6 +118,25 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
});
}
+ pos_profile(frm) {
+ if (!frm.pos_profile || frm.pos_profile == '') {
+ this.update_customer_groups_settings([]);
+ return;
+ }
+
+ frappe.call({
+ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_pos_profile_data",
+ args: { "pos_profile": frm.pos_profile },
+ callback: ({ message: profile }) => {
+ this.update_customer_groups_settings(profile?.customer_groups);
+ },
+ });
+ }
+
+ update_customer_groups_settings(customer_groups) {
+ this.settings.customer_groups = customer_groups?.map((group) => group.name)
+ }
+
amount(){
this.write_off_outstanding_amount_automatically()
}
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
index b126d57400..eedaaaf338 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
@@ -343,7 +343,8 @@
"no_copy": 1,
"options": "POS Invoice",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"default": "0",
@@ -1553,7 +1554,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
- "modified": "2022-03-22 13:00:24.166684",
+ "modified": "2022-09-30 03:49:50.455199",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 6e3a0766f1..a1239d64a0 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -239,14 +239,14 @@ class POSInvoice(SalesInvoice):
frappe.bold(d.warehouse),
frappe.bold(d.qty),
)
- if flt(available_stock) <= 0:
+ if is_stock_item and flt(available_stock) <= 0:
frappe.throw(
_("Row #{}: Item Code: {} is not available under warehouse {}.").format(
d.idx, item_code, warehouse
),
title=_("Item Unavailable"),
)
- elif flt(available_stock) < flt(d.qty):
+ elif is_stock_item and flt(available_stock) < flt(d.qty):
frappe.throw(
_(
"Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}."
@@ -335,7 +335,8 @@ class POSInvoice(SalesInvoice):
if (
self.change_amount
and self.account_for_change_amount
- and frappe.db.get_value("Account", self.account_for_change_amount, "company") != self.company
+ and frappe.get_cached_value("Account", self.account_for_change_amount, "company")
+ != self.company
):
frappe.throw(
_("The selected change account {} doesn't belongs to Company {}.").format(
@@ -486,7 +487,7 @@ class POSInvoice(SalesInvoice):
customer_price_list, customer_group, customer_currency = frappe.db.get_value(
"Customer", self.customer, ["default_price_list", "customer_group", "default_currency"]
)
- customer_group_price_list = frappe.db.get_value(
+ customer_group_price_list = frappe.get_cached_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
@@ -532,8 +533,8 @@ class POSInvoice(SalesInvoice):
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
+ self.party_account_currency = frappe.get_cached_value(
+ "Account", self.debit_to, "account_currency"
)
if not self.due_date and self.customer:
self.due_date = get_due_date(self.posting_date, "Customer", self.customer, self.company)
@@ -632,11 +633,12 @@ def get_stock_availability(item_code, warehouse):
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
return bin_qty - pos_sales_qty, is_stock_item
else:
- is_stock_item = False
+ is_stock_item = True
if frappe.db.exists("Product Bundle", item_code):
return get_bundle_availability(item_code, warehouse), is_stock_item
else:
- # Is a service item
+ is_stock_item = False
+ # Is a service item or non_stock item
return 0, is_stock_item
@@ -650,7 +652,9 @@ def get_bundle_availability(bundle_item_code, warehouse):
available_qty = item_bin_qty - item_pos_reserved_qty
max_available_bundles = available_qty / item.qty
- if bundle_bin_qty > max_available_bundles:
+ if bundle_bin_qty > max_available_bundles and frappe.get_value(
+ "Item", item.item_code, "is_stock_item"
+ ):
bundle_bin_qty = max_available_bundles
pos_sales_qty = get_pos_reserved_qty(bundle_item_code, warehouse)
@@ -671,7 +675,7 @@ def get_bin_qty(item_code, warehouse):
def get_pos_reserved_qty(item_code, warehouse):
reserved_qty = frappe.db.sql(
- """select sum(p_item.qty) as qty
+ """select sum(p_item.stock_qty) as qty
from `tabPOS Invoice` p, `tabPOS Invoice Item` p_item
where p.name = p_item.parent
and ifnull(p.consolidated_invoice, '') = ''
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index 70f128e0e3..3132fdd259 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -495,6 +495,67 @@ class TestPOSInvoice(unittest.TestCase):
self.assertRaises(frappe.ValidationError, pos.submit)
+ def test_value_error_on_serial_no_validation(self):
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
+
+ se = make_serialized_item(
+ company="_Test Company",
+ target_warehouse="Stores - _TC",
+ cost_center="Main - _TC",
+ expense_account="Cost of Goods Sold - _TC",
+ )
+ serial_nos = se.get("items")[0].serial_no
+
+ # make a pos invoice
+ pos = create_pos_invoice(
+ company="_Test Company",
+ debit_to="Debtors - _TC",
+ account_for_change_amount="Cash - _TC",
+ warehouse="Stores - _TC",
+ income_account="Sales - _TC",
+ expense_account="Cost of Goods Sold - _TC",
+ cost_center="Main - _TC",
+ item=se.get("items")[0].item_code,
+ rate=1000,
+ qty=1,
+ do_not_save=1,
+ )
+ pos.get("items")[0].has_serial_no = 1
+ pos.get("items")[0].serial_no = serial_nos.split("\n")[0]
+ pos.set("payments", [])
+ pos.append(
+ "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
+ )
+ pos = pos.save().submit()
+
+ # make a return
+ pos_return = make_sales_return(pos.name)
+ pos_return.paid_amount = pos_return.grand_total
+ pos_return.save()
+ pos_return.submit()
+
+ # set docstatus to 2 for pos to trigger this issue
+ frappe.db.set_value("POS Invoice", pos.name, "docstatus", 2)
+
+ pos2 = create_pos_invoice(
+ company="_Test Company",
+ debit_to="Debtors - _TC",
+ account_for_change_amount="Cash - _TC",
+ warehouse="Stores - _TC",
+ income_account="Sales - _TC",
+ expense_account="Cost of Goods Sold - _TC",
+ cost_center="Main - _TC",
+ item=se.get("items")[0].item_code,
+ rate=1000,
+ qty=1,
+ do_not_save=1,
+ )
+
+ pos2.get("items")[0].has_serial_no = 1
+ pos2.get("items")[0].serial_no = serial_nos.split("\n")[0]
+ # Value error should not be triggered on validation
+ pos2.save()
+
def test_loyalty_points(self):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,
diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
index 3f85668ede..4bb18655b4 100644
--- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
+++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
@@ -8,6 +8,7 @@
"engine": "InnoDB",
"field_order": [
"barcode",
+ "has_item_scanned",
"item_code",
"col_break1",
"item_name",
@@ -808,11 +809,19 @@
"fieldtype": "Check",
"label": "Grant Commission",
"read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "barcode",
+ "fieldname": "has_item_scanned",
+ "fieldtype": "Check",
+ "label": "Has Item Scanned",
+ "read_only": 1
}
],
"istable": 1,
"links": [],
- "modified": "2021-10-05 12:23:47.506290",
+ "modified": "2022-11-02 12:52:39.125295",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",
@@ -820,5 +829,6 @@
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json
index d762087078..a059455647 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json
@@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"posting_date",
+ "posting_time",
"merge_invoices_based_on",
"column_break_3",
"pos_closing_entry",
@@ -105,12 +106,19 @@
"label": "Customer Group",
"mandatory_depends_on": "eval:doc.merge_invoices_based_on == 'Customer Group'",
"options": "Customer Group"
+ },
+ {
+ "fieldname": "posting_time",
+ "fieldtype": "Time",
+ "label": "Posting Time",
+ "no_copy": 1,
+ "reqd": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-09-14 11:17:19.001142",
+ "modified": "2022-08-01 11:36:42.456429",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Merge Log",
@@ -173,5 +181,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
index 5003a1d6a8..b1e22087db 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
@@ -6,11 +6,10 @@ import json
import frappe
from frappe import _
-from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
-from frappe.utils import cint, flt, getdate, nowdate
-from frappe.utils.background_jobs import enqueue
+from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
+from frappe.utils.background_jobs import enqueue, is_job_queued
from frappe.utils.scheduler import is_scheduler_inactive
@@ -18,6 +17,22 @@ class POSInvoiceMergeLog(Document):
def validate(self):
self.validate_customer()
self.validate_pos_invoice_status()
+ self.validate_duplicate_pos_invoices()
+
+ def validate_duplicate_pos_invoices(self):
+ pos_occurences = {}
+ for idx, inv in enumerate(self.pos_invoices, 1):
+ pos_occurences.setdefault(inv.pos_invoice, []).append(idx)
+
+ error_list = []
+ for key, value in pos_occurences.items():
+ if len(value) > 1:
+ error_list.append(
+ _("{} is added multiple times on rows: {}".format(frappe.bold(key), frappe.bold(value)))
+ )
+
+ if error_list:
+ frappe.throw(error_list, title=_("Duplicate POS Invoices found"), as_list=True)
def validate_customer(self):
if self.merge_invoices_based_on == "Customer Group":
@@ -99,6 +114,7 @@ class POSInvoiceMergeLog(Document):
sales_invoice.is_consolidated = 1
sales_invoice.set_posting_time = 1
sales_invoice.posting_date = getdate(self.posting_date)
+ sales_invoice.posting_time = get_time(self.posting_time)
sales_invoice.save()
sales_invoice.submit()
@@ -115,6 +131,7 @@ class POSInvoiceMergeLog(Document):
credit_note.is_consolidated = 1
credit_note.set_posting_time = 1
credit_note.posting_date = getdate(self.posting_date)
+ credit_note.posting_time = get_time(self.posting_time)
# TODO: return could be against multiple sales invoice which could also have been consolidated?
# credit_note.return_against = self.consolidated_invoice
credit_note.save()
@@ -402,6 +419,9 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
merge_log.posting_date = (
getdate(closing_entry.get("posting_date")) if closing_entry else nowdate()
)
+ merge_log.posting_time = (
+ get_time(closing_entry.get("posting_time")) if closing_entry else nowtime()
+ )
merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
@@ -421,12 +441,14 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
if closing_entry:
closing_entry.set_status(update=True, status="Failed")
+ if type(error_message) == list:
+ error_message = frappe.json.dumps(error_message)
closing_entry.db_set("error_message", error_message)
raise
finally:
frappe.db.commit()
- frappe.publish_realtime("closing_process_complete", {"user": frappe.session.user})
+ frappe.publish_realtime("closing_process_complete", user=frappe.session.user)
def cancel_merge_logs(merge_logs, closing_entry=None):
@@ -453,7 +475,7 @@ def cancel_merge_logs(merge_logs, closing_entry=None):
finally:
frappe.db.commit()
- frappe.publish_realtime("closing_process_complete", {"user": frappe.session.user})
+ frappe.publish_realtime("closing_process_complete", user=frappe.session.user)
def enqueue_job(job, **kwargs):
@@ -462,7 +484,7 @@ def enqueue_job(job, **kwargs):
closing_entry = kwargs.get("closing_entry") or {}
job_name = closing_entry.get("name")
- if not job_already_enqueued(job_name):
+ if not is_job_queued(job_name):
enqueue(
job,
**kwargs,
@@ -486,12 +508,6 @@ def check_scheduler_status():
frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive"))
-def job_already_enqueued(job_name):
- enqueued_jobs = [d.get("job_name") for d in get_info()]
- if job_name in enqueued_jobs:
- return True
-
-
def safe_load_json(message):
try:
json_message = json.loads(message).get("message")
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json
index d5f7ee4f21..994b6776e3 100644
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.json
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json
@@ -43,6 +43,7 @@
"currency",
"write_off_account",
"write_off_cost_center",
+ "write_off_limit",
"account_for_change_amount",
"disable_rounded_total",
"column_break_23",
@@ -360,6 +361,14 @@
"fieldtype": "Check",
"label": "Validate Stock on Save"
},
+ {
+ "default": "1",
+ "description": "Auto write off precision loss while consolidation",
+ "fieldname": "write_off_limit",
+ "fieldtype": "Currency",
+ "label": "Write Off Limit",
+ "reqd": 1
+ },
{
"default": "0",
"description": "If enabled, the consolidated invoices will have rounded total disabled",
@@ -393,7 +402,7 @@
"link_fieldname": "pos_profile"
}
],
- "modified": "2022-07-21 11:16:46.911173",
+ "modified": "2022-08-10 12:57:06.241439",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
index 99c5b34fa3..a63039e0e3 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
@@ -52,7 +52,10 @@
"free_item_rate",
"column_break_42",
"free_item_uom",
+ "round_free_qty",
"is_recursive",
+ "recurse_for",
+ "apply_recursion_over",
"section_break_23",
"valid_from",
"valid_upto",
@@ -176,7 +179,7 @@
},
{
"collapsible": 1,
- "depends_on": "eval:doc.apply_on != 'Transaction'",
+ "depends_on": "eval:doc.apply_on != 'Transaction' && !doc.mixed_conditions",
"fieldname": "section_break_18",
"fieldtype": "Section Break",
"label": "Discount on Other Item"
@@ -297,12 +300,12 @@
{
"fieldname": "min_qty",
"fieldtype": "Float",
- "label": "Min Qty"
+ "label": "Min Qty (As Per Stock UOM)"
},
{
"fieldname": "max_qty",
"fieldtype": "Float",
- "label": "Max Qty"
+ "label": "Max Qty (As Per Stock UOM)"
},
{
"fieldname": "column_break_21",
@@ -469,7 +472,7 @@
"description": "If rate is zero them item will be treated as \"Free Item\"",
"fieldname": "free_item_rate",
"fieldtype": "Currency",
- "label": "Rate"
+ "label": "Free Item Rate"
},
{
"collapsible": 1,
@@ -481,7 +484,7 @@
"description": "System will notify to increase or decrease quantity or amount ",
"fieldname": "threshold_percentage",
"fieldtype": "Percent",
- "label": "Threshold for Suggestion"
+ "label": "Threshold for Suggestion (In Percentage)"
},
{
"description": "Higher the number, higher the priority",
@@ -578,15 +581,38 @@
"fieldtype": "Select",
"label": "Naming Series",
"options": "PRLE-.####"
+ },
+ {
+ "default": "0",
+ "fieldname": "round_free_qty",
+ "fieldtype": "Check",
+ "label": "Round Free Qty"
+ },
+ {
+ "depends_on": "is_recursive",
+ "description": "Give free item for every N quantity",
+ "fieldname": "recurse_for",
+ "fieldtype": "Float",
+ "label": "Recurse Every (As Per Transaction UOM)",
+ "mandatory_depends_on": "is_recursive"
+ },
+ {
+ "default": "0",
+ "depends_on": "is_recursive",
+ "description": "Qty for which recursion isn't applicable.",
+ "fieldname": "apply_recursion_over",
+ "fieldtype": "Float",
+ "label": "Apply Recursion Over (As Per Transaction UOM)"
}
],
"icon": "fa fa-gift",
"idx": 1,
"links": [],
- "modified": "2021-08-06 15:10:04.219321",
+ "modified": "2023-02-14 04:53:34.887358",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -642,5 +668,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "title"
-}
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index 98e0a9b215..2943500cf4 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -10,7 +10,7 @@ import re
import frappe
from frappe import _, throw
from frappe.model.document import Document
-from frappe.utils import cint, flt, getdate
+from frappe.utils import cint, flt
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
@@ -24,6 +24,7 @@ class PricingRule(Document):
self.validate_applicable_for_selling_or_buying()
self.validate_min_max_amt()
self.validate_min_max_qty()
+ self.validate_recursion()
self.cleanup_fields_value()
self.validate_rate_or_discount()
self.validate_max_discount()
@@ -109,6 +110,18 @@ class PricingRule(Document):
if self.min_amt and self.max_amt and flt(self.min_amt) > flt(self.max_amt):
throw(_("Min Amt can not be greater than Max Amt"))
+ def validate_recursion(self):
+ if self.price_or_product_discount != "Product":
+ return
+ if self.free_item or self.same_item:
+ if flt(self.recurse_for) <= 0:
+ self.recurse_for = 1
+ if self.is_recursive:
+ if flt(self.apply_recursion_over) > flt(self.min_qty):
+ throw(_("Min Qty should be greater than Recurse Over Qty"))
+ if flt(self.apply_recursion_over) < 0:
+ throw(_("Recurse Over Qty cannot be less than 0"))
+
def cleanup_fields_value(self):
for logic_field in ["apply_on", "applicable_for", "rate_or_discount"]:
fieldname = frappe.scrub(self.get(logic_field) or "")
@@ -171,8 +184,7 @@ class PricingRule(Document):
if self.is_cumulative and not (self.valid_from and self.valid_upto):
frappe.throw(_("Valid from and valid upto fields are mandatory for the cumulative"))
- if self.valid_from and self.valid_upto and getdate(self.valid_from) > getdate(self.valid_upto):
- frappe.throw(_("Valid from date must be less than valid upto date"))
+ self.validate_from_to_dates("valid_from", "valid_upto")
def validate_condition(self):
if (
@@ -243,7 +255,7 @@ def apply_pricing_rule(args, doc=None):
for item in item_list:
args_copy = copy.deepcopy(args)
args_copy.update(item)
- data = get_pricing_rule_for_item(args_copy, item.get("price_list_rate"), doc=doc)
+ data = get_pricing_rule_for_item(args_copy, doc=doc)
out.append(data)
if (
@@ -268,7 +280,19 @@ def get_serial_no_for_item(args):
return item_details
-def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=False):
+def update_pricing_rule_uom(pricing_rule, args):
+ child_doc = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}.get(
+ pricing_rule.apply_on
+ )
+
+ apply_on_field = frappe.scrub(pricing_rule.apply_on)
+
+ for row in pricing_rule.get(child_doc):
+ if row.get(apply_on_field) == args.get(apply_on_field):
+ pricing_rule.uom = row.uom
+
+
+def get_pricing_rule_for_item(args, doc=None, for_validate=False):
from erpnext.accounts.doctype.pricing_rule.utils import (
get_applied_pricing_rules,
get_pricing_rule_items,
@@ -324,7 +348,8 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
if isinstance(pricing_rule, str):
pricing_rule = frappe.get_cached_doc("Pricing Rule", pricing_rule)
- pricing_rule.apply_rule_on_other_items = get_pricing_rule_items(pricing_rule)
+ update_pricing_rule_uom(pricing_rule, args)
+ pricing_rule.apply_rule_on_other_items = get_pricing_rule_items(pricing_rule) or []
if pricing_rule.get("suggestion"):
continue
@@ -337,7 +362,6 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
if pricing_rule.mixed_conditions or pricing_rule.apply_rule_on_other:
item_details.update(
{
- "apply_rule_on_other_items": json.dumps(pricing_rule.apply_rule_on_other_items),
"price_or_product_discount": pricing_rule.price_or_product_discount,
"apply_rule_on": (
frappe.scrub(pricing_rule.apply_rule_on_other)
@@ -347,6 +371,9 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa
}
)
+ if pricing_rule.apply_rule_on_other_items:
+ item_details["apply_rule_on_other_items"] = json.dumps(pricing_rule.apply_rule_on_other_items)
+
if pricing_rule.coupon_code_based == 1 and args.coupon_code == None:
return item_details
@@ -438,12 +465,15 @@ def apply_price_discount_rule(pricing_rule, item_details, args):
if pricing_rule.currency == args.currency:
pricing_rule_rate = pricing_rule.rate
+ # TODO https://github.com/frappe/erpnext/pull/23636 solve this in some other way.
if pricing_rule_rate:
+ is_blank_uom = pricing_rule.get("uom") != args.get("uom")
# Override already set price list rate (from item price)
# if pricing_rule_rate > 0
item_details.update(
{
- "price_list_rate": pricing_rule_rate * args.get("conversion_factor", 1),
+ "price_list_rate": pricing_rule_rate
+ * (args.get("conversion_factor", 1) if is_blank_uom else 1),
}
)
item_details.update({"discount_percentage": 0.0})
@@ -492,7 +522,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, ra
)
if pricing_rule.get("mixed_conditions") or pricing_rule.get("apply_rule_on_other"):
- items = get_pricing_rule_items(pricing_rule)
+ items = get_pricing_rule_items(pricing_rule, other_items=True)
item_details.apply_on = (
frappe.scrub(pricing_rule.apply_rule_on_other)
if pricing_rule.apply_rule_on_other
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 3bd0cd2e83..5bb366a770 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -595,6 +595,247 @@ class TestPricingRule(unittest.TestCase):
frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete()
item.delete()
+ def test_item_price_with_blank_uom_pricing_rule(self):
+ properties = {
+ "item_code": "Item Blank UOM",
+ "stock_uom": "Nos",
+ "sales_uom": "Box",
+ "uoms": [dict(uom="Box", conversion_factor=10)],
+ }
+ item = make_item(properties=properties)
+
+ make_item_price("Item Blank UOM", "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Item Blank UOM Rule",
+ "apply_on": "Item Code",
+ "items": [
+ {
+ "item_code": "Item Blank UOM",
+ }
+ ],
+ "selling": 1,
+ "currency": "INR",
+ "rate_or_discount": "Rate",
+ "rate": 101,
+ "company": "_Test Company",
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(
+ do_not_save=True, item_code="Item Blank UOM", uom="Box", conversion_factor=10
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # If UOM is blank consider it as stock UOM and apply pricing_rule on all UOM.
+ # rate is 101, Selling UOM is Box that have conversion_factor of 10 so 101 * 10 = 1010
+ self.assertEqual(si.items[0].price_list_rate, 1010)
+ self.assertEqual(si.items[0].rate, 1010)
+
+ si.delete()
+
+ si = create_sales_invoice(do_not_save=True, item_code="Item Blank UOM", uom="Nos")
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is blank so consider it as stock UOM and apply pricing_rule on all UOM.
+ # rate is 101, Selling UOM is Nos that have conversion_factor of 1 so 101 * 1 = 101
+ self.assertEqual(si.items[0].price_list_rate, 101)
+ self.assertEqual(si.items[0].rate, 101)
+
+ si.delete()
+ rule.delete()
+ frappe.get_doc("Item Price", {"item_code": "Item Blank UOM"}).delete()
+
+ item.delete()
+
+ def test_item_price_with_selling_uom_pricing_rule(self):
+ properties = {
+ "item_code": "Item UOM other than Stock",
+ "stock_uom": "Nos",
+ "sales_uom": "Box",
+ "uoms": [dict(uom="Box", conversion_factor=10)],
+ }
+ item = make_item(properties=properties)
+
+ make_item_price("Item UOM other than Stock", "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Item UOM other than Stock Rule",
+ "apply_on": "Item Code",
+ "items": [
+ {
+ "item_code": "Item UOM other than Stock",
+ "uom": "Box",
+ }
+ ],
+ "selling": 1,
+ "currency": "INR",
+ "rate_or_discount": "Rate",
+ "rate": 101,
+ "company": "_Test Company",
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(
+ do_not_save=True, item_code="Item UOM other than Stock", uom="Box", conversion_factor=10
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is Box so apply pricing_rule only on Box UOM.
+ # Selling UOM is Box and as both UOM are same no need to multiply by conversion_factor.
+ self.assertEqual(si.items[0].price_list_rate, 101)
+ self.assertEqual(si.items[0].rate, 101)
+
+ si.delete()
+
+ si = create_sales_invoice(do_not_save=True, item_code="Item UOM other than Stock", uom="Nos")
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is Box so pricing_rule won't apply as selling_uom is Nos.
+ # As Pricing Rule is not applied price of 100 will be fetched from Item Price List.
+ self.assertEqual(si.items[0].price_list_rate, 100)
+ self.assertEqual(si.items[0].rate, 100)
+
+ si.delete()
+ rule.delete()
+ frappe.get_doc("Item Price", {"item_code": "Item UOM other than Stock"}).delete()
+
+ item.delete()
+
+ def test_item_group_price_with_blank_uom_pricing_rule(self):
+ group = frappe.get_doc(doctype="Item Group", item_group_name="_Test Pricing Rule Item Group")
+ group.save()
+ properties = {
+ "item_code": "Item with Group Blank UOM",
+ "item_group": "_Test Pricing Rule Item Group",
+ "stock_uom": "Nos",
+ "sales_uom": "Box",
+ "uoms": [dict(uom="Box", conversion_factor=10)],
+ }
+ item = make_item(properties=properties)
+
+ make_item_price("Item with Group Blank UOM", "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Item with Group Blank UOM Rule",
+ "apply_on": "Item Group",
+ "item_groups": [
+ {
+ "item_group": "_Test Pricing Rule Item Group",
+ }
+ ],
+ "selling": 1,
+ "currency": "INR",
+ "rate_or_discount": "Rate",
+ "rate": 101,
+ "company": "_Test Company",
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(
+ do_not_save=True, item_code="Item with Group Blank UOM", uom="Box", conversion_factor=10
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # If UOM is blank consider it as stock UOM and apply pricing_rule on all UOM.
+ # rate is 101, Selling UOM is Box that have conversion_factor of 10 so 101 * 10 = 1010
+ self.assertEqual(si.items[0].price_list_rate, 1010)
+ self.assertEqual(si.items[0].rate, 1010)
+
+ si.delete()
+
+ si = create_sales_invoice(do_not_save=True, item_code="Item with Group Blank UOM", uom="Nos")
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is blank so consider it as stock UOM and apply pricing_rule on all UOM.
+ # rate is 101, Selling UOM is Nos that have conversion_factor of 1 so 101 * 1 = 101
+ self.assertEqual(si.items[0].price_list_rate, 101)
+ self.assertEqual(si.items[0].rate, 101)
+
+ si.delete()
+ rule.delete()
+ frappe.get_doc("Item Price", {"item_code": "Item with Group Blank UOM"}).delete()
+ item.delete()
+ group.delete()
+
+ def test_item_group_price_with_selling_uom_pricing_rule(self):
+ group = frappe.get_doc(doctype="Item Group", item_group_name="_Test Pricing Rule Item Group UOM")
+ group.save()
+ properties = {
+ "item_code": "Item with Group UOM other than Stock",
+ "item_group": "_Test Pricing Rule Item Group UOM",
+ "stock_uom": "Nos",
+ "sales_uom": "Box",
+ "uoms": [dict(uom="Box", conversion_factor=10)],
+ }
+ item = make_item(properties=properties)
+
+ make_item_price("Item with Group UOM other than Stock", "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Item with Group UOM other than Stock Rule",
+ "apply_on": "Item Group",
+ "item_groups": [
+ {
+ "item_group": "_Test Pricing Rule Item Group UOM",
+ "uom": "Box",
+ }
+ ],
+ "selling": 1,
+ "currency": "INR",
+ "rate_or_discount": "Rate",
+ "rate": 101,
+ "company": "_Test Company",
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(
+ do_not_save=True,
+ item_code="Item with Group UOM other than Stock",
+ uom="Box",
+ conversion_factor=10,
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is Box so apply pricing_rule only on Box UOM.
+ # Selling UOM is Box and as both UOM are same no need to multiply by conversion_factor.
+ self.assertEqual(si.items[0].price_list_rate, 101)
+ self.assertEqual(si.items[0].rate, 101)
+
+ si.delete()
+
+ si = create_sales_invoice(
+ do_not_save=True, item_code="Item with Group UOM other than Stock", uom="Nos"
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ # UOM is Box so pricing_rule won't apply as selling_uom is Nos.
+ # As Pricing Rule is not applied price of 100 will be fetched from Item Price List.
+ self.assertEqual(si.items[0].price_list_rate, 100)
+ self.assertEqual(si.items[0].rate, 100)
+
+ si.delete()
+ rule.delete()
+ frappe.get_doc("Item Price", {"item_code": "Item with Group UOM other than Stock"}).delete()
+ item.delete()
+ group.delete()
+
def test_pricing_rule_for_different_currency(self):
make_item("Test Sanitizer Item")
@@ -766,6 +1007,107 @@ class TestPricingRule(unittest.TestCase):
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
+ def test_pricing_rule_for_other_items_cond_with_amount(self):
+ item = make_item("Water Flask New")
+ other_item = make_item("Other Water Flask New")
+ make_item_price(item.name, "_Test Price List", 100)
+ make_item_price(other_item.name, "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Water Flask Rule",
+ "apply_on": "Item Code",
+ "apply_rule_on_other": "Item Code",
+ "price_or_product_discount": "Price",
+ "rate_or_discount": "Discount Percentage",
+ "other_item_code": other_item.name,
+ "items": [
+ {
+ "item_code": item.name,
+ }
+ ],
+ "selling": 1,
+ "currency": "INR",
+ "min_amt": 200,
+ "discount_percentage": 10,
+ "company": "_Test Company",
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(do_not_save=True, item_code=item.name)
+ si.append(
+ "items",
+ {
+ "item_code": other_item.name,
+ "item_name": other_item.item_name,
+ "description": other_item.description,
+ "stock_uom": other_item.stock_uom,
+ "uom": other_item.stock_uom,
+ "cost_center": si.items[0].cost_center,
+ "expense_account": si.items[0].expense_account,
+ "warehouse": si.items[0].warehouse,
+ "conversion_factor": 1,
+ "qty": 1,
+ },
+ )
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ self.assertEqual(si.items[0].discount_percentage, 0)
+ self.assertEqual(si.items[1].discount_percentage, 0)
+
+ si.items[0].qty = 2
+ si.save()
+
+ self.assertEqual(si.items[0].discount_percentage, 0)
+ self.assertEqual(si.items[0].stock_qty, 2)
+ self.assertEqual(si.items[0].amount, 200)
+ self.assertEqual(si.items[0].price_list_rate, 100)
+ self.assertEqual(si.items[1].discount_percentage, 10)
+
+ si.delete()
+ rule.delete()
+
+ def test_pricing_rule_for_product_free_item_rounded_qty_and_recursion(self):
+ frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule")
+ test_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Pricing Rule",
+ "apply_on": "Item Code",
+ "currency": "USD",
+ "items": [
+ {
+ "item_code": "_Test Item",
+ }
+ ],
+ "selling": 1,
+ "rate": 0,
+ "min_qty": 3,
+ "max_qty": 7,
+ "price_or_product_discount": "Product",
+ "same_item": 1,
+ "free_qty": 1,
+ "round_free_qty": 1,
+ "is_recursive": 1,
+ "recurse_for": 2,
+ "company": "_Test Company",
+ }
+ frappe.get_doc(test_record.copy()).insert()
+
+ # With pricing rule
+ so = make_sales_order(item_code="_Test Item", qty=5)
+ so.load_from_db()
+ self.assertEqual(so.items[1].is_free_item, 1)
+ self.assertEqual(so.items[1].item_code, "_Test Item")
+ self.assertEqual(so.items[1].qty, 2)
+
+ so = make_sales_order(item_code="_Test Item", qty=7)
+ so.load_from_db()
+ self.assertEqual(so.items[1].is_free_item, 1)
+ self.assertEqual(so.items[1].item_code, "_Test Item")
+ self.assertEqual(so.items[1].qty, 4)
+
test_dependencies = ["Campaign"]
@@ -781,7 +1123,7 @@ def make_pricing_rule(**args):
"apply_on": args.apply_on or "Item Code",
"applicable_for": args.applicable_for,
"selling": args.selling or 0,
- "currency": "USD",
+ "currency": "INR",
"apply_discount_on_rate": args.apply_discount_on_rate or 0,
"buying": args.buying or 0,
"min_qty": args.min_qty or 0.0,
diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py
index 70926cfbd7..57feaa03eb 100644
--- a/erpnext/accounts/doctype/pricing_rule/utils.py
+++ b/erpnext/accounts/doctype/pricing_rule/utils.py
@@ -111,6 +111,12 @@ def _get_pricing_rules(apply_on, args, values):
)
if apply_on_field == "item_code":
+ if args.get("uom", None):
+ item_conditions += (
+ " and ({child_doc}.uom='{item_uom}' or IFNULL({child_doc}.uom, '')='')".format(
+ child_doc=child_doc, item_uom=args.get("uom")
+ )
+ )
if "variant_of" not in args:
args.variant_of = frappe.get_cached_value("Item", args.item_code, "variant_of")
@@ -121,6 +127,12 @@ def _get_pricing_rules(apply_on, args, values):
values["variant_of"] = args.variant_of
elif apply_on_field == "item_group":
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
+ if args.get("uom", None):
+ item_conditions += (
+ " and ({child_doc}.uom='{item_uom}' or IFNULL({child_doc}.uom, '')='')".format(
+ child_doc=child_doc, item_uom=args.get("uom")
+ )
+ )
conditions += get_other_conditions(conditions, values, args)
warehouse_conditions = _get_tree_conditions(args, "Warehouse", "`tabPricing Rule`")
@@ -238,6 +250,22 @@ def get_other_conditions(conditions, values, args):
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = args.get("transaction_date")
+ if args.get("doctype") in [
+ "Quotation",
+ "Quotation Item",
+ "Sales Order",
+ "Sales Order Item",
+ "Delivery Note",
+ "Delivery Note Item",
+ "Sales Invoice",
+ "Sales Invoice Item",
+ "POS Invoice",
+ "POS Invoice Item",
+ ]:
+ conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
+ else:
+ conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
+
return conditions
@@ -252,12 +280,6 @@ def filter_pricing_rules(args, pricing_rules, doc=None):
stock_qty = flt(args.get("stock_qty"))
amount = flt(args.get("price_list_rate")) * flt(args.get("qty"))
- if pricing_rules[0].apply_rule_on_other:
- field = frappe.scrub(pricing_rules[0].apply_rule_on_other)
-
- if field and pricing_rules[0].get("other_" + field) != args.get(field):
- return
-
pr_doc = frappe.get_cached_doc("Pricing Rule", pricing_rules[0].name)
if pricing_rules[0].mixed_conditions and doc:
@@ -274,7 +296,7 @@ def filter_pricing_rules(args, pricing_rules, doc=None):
amount += data[1]
if pricing_rules[0].apply_rule_on_other and not pricing_rules[0].mixed_conditions and doc:
- pricing_rules = get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules) or []
+ pricing_rules = get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules, args) or []
else:
pricing_rules = filter_pricing_rules_for_qty_amount(stock_qty, amount, pricing_rules, args)
@@ -352,16 +374,14 @@ def validate_quantity_and_amount_for_suggestion(args, qty, amount, item_code, tr
if fieldname:
msg = _(
"If you {0} {1} quantities of the item {2}, the scheme {3} will be applied on the item."
- ).format(
- type_of_transaction, args.get(fieldname), bold(item_code), bold(args.rule_description)
- )
+ ).format(type_of_transaction, args.get(fieldname), bold(item_code), bold(args.title))
if fieldname in ["min_amt", "max_amt"]:
msg = _("If you {0} {1} worth item {2}, the scheme {3} will be applied on the item.").format(
type_of_transaction,
fmt_money(args.get(fieldname), currency=args.get("currency")),
bold(item_code),
- bold(args.rule_description),
+ bold(args.title),
)
frappe.msgprint(msg)
@@ -454,17 +474,29 @@ def get_qty_and_rate_for_mixed_conditions(doc, pr_doc, args):
return sum_qty, sum_amt, items
-def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules):
- items = get_pricing_rule_items(pr_doc)
+def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules, row_item):
+ other_items = get_pricing_rule_items(pr_doc, other_items=True)
+ pricing_rule_apply_on = apply_on_table.get(pr_doc.get("apply_on"))
+ apply_on = frappe.scrub(pr_doc.get("apply_on"))
+
+ items = []
+ for d in pr_doc.get(pricing_rule_apply_on):
+ if apply_on == "item_group":
+ items.extend(get_child_item_groups(d.get(apply_on)))
+ else:
+ items.append(d.get(apply_on))
for row in doc.items:
- if row.get(frappe.scrub(pr_doc.apply_rule_on_other)) in items:
- pricing_rules = filter_pricing_rules_for_qty_amount(
- row.get("stock_qty"), row.get("amount"), pricing_rules, row
- )
+ if row.get(apply_on) in items:
+ if not row.get("qty"):
+ continue
+
+ stock_qty = row.get("qty") * (row.get("conversion_factor") or 1.0)
+ amount = stock_qty * (row.get("price_list_rate") or row.get("rate"))
+ pricing_rules = filter_pricing_rules_for_qty_amount(stock_qty, amount, pricing_rules, row)
if pricing_rules and pricing_rules[0]:
- pricing_rules[0].apply_rule_on_other_items = items
+ pricing_rules[0].apply_rule_on_other_items = other_items
return pricing_rules
@@ -617,9 +649,13 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
qty = pricing_rule.free_qty or 1
if pricing_rule.is_recursive:
- transaction_qty = args.get("qty") if args else doc.total_qty
+ transaction_qty = (
+ args.get("qty") if args else doc.total_qty
+ ) - pricing_rule.apply_recursion_over
if transaction_qty:
- qty = flt(transaction_qty) * qty
+ qty = flt(transaction_qty) * qty / pricing_rule.recurse_for
+ if pricing_rule.round_free_qty:
+ qty = round(qty)
free_item_data_args = {
"item_code": free_item,
@@ -649,30 +685,40 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
item_details.free_item_data.append(free_item_data_args)
-def apply_pricing_rule_for_free_items(doc, pricing_rule_args, set_missing_values=False):
+def apply_pricing_rule_for_free_items(doc, pricing_rule_args):
if pricing_rule_args:
- items = tuple((d.item_code, d.pricing_rules) for d in doc.items if d.is_free_item)
+ args = {(d["item_code"], d["pricing_rules"]): d for d in pricing_rule_args}
- for args in pricing_rule_args:
- if not items or (args.get("item_code"), args.get("pricing_rules")) not in items:
- doc.append("items", args)
+ for item in doc.items:
+ if not item.is_free_item:
+ continue
+
+ free_item_data = args.get((item.item_code, item.pricing_rules))
+ if free_item_data:
+ free_item_data.pop("item_name")
+ free_item_data.pop("description")
+ item.update(free_item_data)
+ args.pop((item.item_code, item.pricing_rules))
+
+ for free_item in args.values():
+ doc.append("items", free_item)
-def get_pricing_rule_items(pr_doc):
+def get_pricing_rule_items(pr_doc, other_items=False) -> list:
apply_on_data = []
apply_on = frappe.scrub(pr_doc.get("apply_on"))
pricing_rule_apply_on = apply_on_table.get(pr_doc.get("apply_on"))
- for d in pr_doc.get(pricing_rule_apply_on):
- if apply_on == "item_group":
- apply_on_data.extend(get_child_item_groups(d.get(apply_on)))
- else:
- apply_on_data.append(d.get(apply_on))
-
- if pr_doc.apply_rule_on_other:
+ if pr_doc.apply_rule_on_other and other_items:
apply_on = frappe.scrub(pr_doc.apply_rule_on_other)
apply_on_data.append(pr_doc.get("other_" + apply_on))
+ else:
+ for d in pr_doc.get(pricing_rule_apply_on):
+ if apply_on == "item_group":
+ apply_on_data.extend(get_child_item_groups(d.get(apply_on)))
+ else:
+ apply_on_data.append(d.get(apply_on))
return list(set(apply_on_data))
diff --git a/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py b/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
index 8ec726b36c..1f88849b26 100644
--- a/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
+++ b/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
@@ -34,4 +34,4 @@ class ProcessDeferredAccounting(Document):
filters={"against_voucher_type": self.doctype, "against_voucher": self.name},
)
- make_gl_entries(gl_entries=gl_entries, cancel=1)
+ make_gl_entries(gl_map=gl_entries, cancel=1)
diff --git a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py
index 164ba6aa34..5a0aeb7284 100644
--- a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py
+++ b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py
@@ -57,3 +57,16 @@ class TestProcessDeferredAccounting(unittest.TestCase):
]
check_gl_entries(self, si.name, expected_gle, "2019-01-10")
+
+ def test_pda_submission_and_cancellation(self):
+ pda = frappe.get_doc(
+ dict(
+ doctype="Process Deferred Accounting",
+ posting_date="2019-01-01",
+ start_date="2019-01-01",
+ end_date="2019-01-31",
+ type="Income",
+ )
+ )
+ pda.submit()
+ pda.cancel()
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
index 82705a9cea..3920d4cf09 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
@@ -25,7 +25,7 @@
-
+
{{ _("Date") }} |
@@ -49,7 +49,6 @@
{% endif %}
- {{ _("Against") }}: {{ row.against }}
{{ _("Remarks") }}: {{ row.remarks }}
{% if row.bill_no %}
{{ _("Supplier Invoice No") }}: {{ row.bill_no }}
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
index 7dd77fbb3c..7dd5ef36f2 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
@@ -9,6 +9,7 @@ frappe.ui.form.on('Process Statement Of Accounts', {
refresh: function(frm){
if(!frm.doc.__islocal) {
frm.add_custom_button(__('Send Emails'), function(){
+ if (frm.is_dirty()) frappe.throw(__("Please save before proceeding."))
frappe.call({
method: "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_emails",
args: {
@@ -25,7 +26,8 @@ frappe.ui.form.on('Process Statement Of Accounts', {
});
});
frm.add_custom_button(__('Download'), function(){
- var url = frappe.urllib.get_full_url(
+ if (frm.is_dirty()) frappe.throw(__("Please save before proceeding."))
+ let url = frappe.urllib.get_full_url(
'/api/method/erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.download_statements?'
+ 'document_name='+encodeURIComponent(frm.doc.name))
$.ajax({
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
index a26267ba5e..16602d317a 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
@@ -23,10 +23,12 @@
"fetch_customers",
"column_break_6",
"primary_mandatory",
+ "show_net_values_in_party_account",
"column_break_17",
"customers",
"preferences",
"orientation",
+ "include_break",
"include_ageing",
"ageing_based_on",
"section_break_14",
@@ -284,10 +286,22 @@
"fieldtype": "Link",
"label": "Terms and Conditions",
"options": "Terms and Conditions"
+ },
+ {
+ "default": "1",
+ "fieldname": "include_break",
+ "fieldtype": "Check",
+ "label": "Page Break After Each SoA"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_net_values_in_party_account",
+ "fieldtype": "Check",
+ "label": "Show Net Values in Party Account"
}
],
"links": [],
- "modified": "2021-09-06 21:00:45.732505",
+ "modified": "2022-11-10 17:44:17.165991",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",
@@ -321,5 +335,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
index 01f716daa2..a48c0272ff 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
@@ -6,6 +6,7 @@ import copy
import frappe
from frappe import _
+from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.utils import add_days, add_months, format_date, getdate, today
from frappe.utils.jinja import validate_template
@@ -94,6 +95,7 @@ def get_report_pdf(doc, consolidated=True):
"show_opening_entries": 0,
"include_default_book_entries": 0,
"tax_id": tax_id if tax_id else None,
+ "show_net_values_in_party_account": doc.show_net_values_in_party_account,
}
)
col, res = get_soa(filters)
@@ -128,7 +130,8 @@ def get_report_pdf(doc, consolidated=True):
if not bool(statement_dict):
return False
elif consolidated:
- result = "".join(list(statement_dict.values()))
+ delimiter = '' if doc.include_break else ""
+ result = delimiter.join(list(statement_dict.values()))
return get_pdf(result, {"orientation": doc.orientation})
else:
for customer, statement_html in statement_dict.items():
@@ -240,8 +243,6 @@ def fetch_customers(customer_collection, collection_name, primary_mandatory):
if int(primary_mandatory):
if primary_email == "":
continue
- elif (billing_email == "") and (primary_email == ""):
- continue
customer_list.append(
{"name": customer.name, "primary_email": primary_email, "billing_email": billing_email}
@@ -273,8 +274,12 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
link.link_doctype='Customer'
and link.link_name=%s
and contact.is_billing_contact=1
+ {mcond}
ORDER BY
- contact.creation desc""",
+ contact.creation desc
+ """.format(
+ mcond=get_match_cond("Contact")
+ ),
customer_name,
)
@@ -313,6 +318,8 @@ def send_emails(document_name, from_scheduler=False):
attachments = [{"fname": customer + ".pdf", "fcontent": report_pdf}]
recipients, cc = get_recipients_and_cc(customer, doc)
+ if not recipients:
+ continue
context = get_context(customer, doc)
subject = frappe.render_template(doc.subject, context)
message = frappe.render_template(doc.body, context)
diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
index fac9be7bdb..4d28d10660 100644
--- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
+++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
@@ -34,8 +34,8 @@ pricing_rule_fields = [
other_fields = [
"min_qty",
"max_qty",
- "min_amt",
- "max_amt",
+ "min_amount",
+ "max_amount",
"priority",
"warehouse",
"threshold_percentage",
@@ -246,7 +246,11 @@ def prepare_pricing_rule(
def set_args(args, pr, doc, child_doc, discount_fields, child_doc_fields):
pr.update(args)
for field in other_fields + discount_fields:
- pr.set(field, child_doc_fields.get(field))
+ target_field = field
+ if target_field in ["min_amount", "max_amount"]:
+ target_field = "min_amt" if field == "min_amount" else "max_amt"
+
+ pr.set(target_field, child_doc_fields.get(field))
pr.promotional_scheme_id = child_doc_fields.name
pr.promotional_scheme = doc.name
diff --git a/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py
index b3b9d7b208..9e576fb877 100644
--- a/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py
+++ b/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py
@@ -90,6 +90,23 @@ class TestPromotionalScheme(unittest.TestCase):
price_rules = frappe.get_all("Pricing Rule", filters={"promotional_scheme": ps.name})
self.assertEqual(price_rules, [])
+ def test_min_max_amount_configuration(self):
+ ps = make_promotional_scheme()
+ ps.price_discount_slabs[0].min_amount = 10
+ ps.price_discount_slabs[0].max_amount = 1000
+ ps.save()
+
+ price_rules_data = frappe.db.get_value(
+ "Pricing Rule", {"promotional_scheme": ps.name}, ["min_amt", "max_amt"], as_dict=1
+ )
+
+ self.assertEqual(price_rules_data.min_amt, 10)
+ self.assertEqual(price_rules_data.max_amt, 1000)
+
+ frappe.delete_doc("Promotional Scheme", ps.name)
+ price_rules = frappe.get_all("Pricing Rule", filters={"promotional_scheme": ps.name})
+ self.assertEqual(price_rules, [])
+
def make_promotional_scheme(**args):
args = frappe._dict(args)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index ec861a2787..e2b4a1ad5b 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -31,7 +31,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
super.onload();
// Ignore linked advances
- this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry'];
+ this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"];
if(!this.frm.doc.__islocal) {
// show credit_to in print format
@@ -81,7 +81,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
if(doc.docstatus == 1 && doc.outstanding_amount != 0
- && !(doc.is_return && doc.return_against)) {
+ && !(doc.is_return && doc.return_against) && !doc.on_hold) {
this.frm.add_custom_button(__('Payment'), this.make_payment_entry, __('Create'));
cur_frm.page.set_inner_btn_group_as_primary(__('Create'));
}
@@ -99,7 +99,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
}
- if (doc.outstanding_amount > 0 && !cint(doc.is_return)) {
+ if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
cur_frm.add_custom_button(__('Payment Request'), function() {
me.make_payment_request()
}, __('Create'));
@@ -569,6 +569,10 @@ frappe.ui.form.on("Purchase Invoice", {
erpnext.queries.setup_queries(frm, "Warehouse", function() {
return erpnext.queries.warehouse(frm.doc);
});
+
+ if (frm.is_new()) {
+ frm.clear_table("tax_withheld_vouchers");
+ }
},
is_subcontracted: function(frm) {
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
index 534b879e78..54caf6f8b0 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
@@ -12,39 +12,27 @@
"supplier",
"supplier_name",
"tax_id",
- "due_date",
- "tax_withholding_category",
- "column_break1",
"company",
+ "column_break_6",
"posting_date",
"posting_time",
"set_posting_time",
+ "due_date",
+ "column_break1",
"is_paid",
"is_return",
+ "return_against",
"apply_tds",
+ "tax_withholding_category",
"amended_from",
- "accounting_dimensions_section",
- "cost_center",
- "dimension_col_break",
- "project",
"supplier_invoice_details",
"bill_no",
"column_break_15",
"bill_date",
- "returns",
- "return_against",
- "section_addresses",
- "supplier_address",
- "address_display",
- "contact_person",
- "contact_display",
- "contact_mobile",
- "contact_email",
- "col_break_address",
- "shipping_address",
- "shipping_address_display",
- "billing_address",
- "billing_address_display",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break",
+ "project",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -54,37 +42,37 @@
"plc_conversion_rate",
"ignore_pricing_rule",
"sec_warehouse",
- "set_warehouse",
- "rejected_warehouse",
- "col_break_warehouse",
- "set_from_warehouse",
- "supplier_warehouse",
- "is_subcontracted",
- "items_section",
- "update_stock",
"scan_barcode",
+ "col_break_warehouse",
+ "update_stock",
+ "set_warehouse",
+ "set_from_warehouse",
+ "is_subcontracted",
+ "rejected_warehouse",
+ "supplier_warehouse",
+ "items_section",
"items",
- "pricing_rule_details",
- "pricing_rules",
- "raw_materials_supplied",
- "supplied_items",
"section_break_26",
"total_qty",
+ "total_net_weight",
+ "column_break_50",
"base_total",
"base_net_total",
"column_break_28",
- "total_net_weight",
"total",
"net_total",
+ "tax_withholding_net_total",
+ "base_tax_withholding_net_total",
"taxes_section",
"tax_category",
- "column_break_49",
- "shipping_rule",
- "section_break_51",
"taxes_and_charges",
+ "column_break_58",
+ "shipping_rule",
+ "column_break_49",
+ "incoterm",
+ "named_place",
+ "section_break_51",
"taxes",
- "sec_tax_breakup",
- "other_charges_calculation",
"totals",
"base_taxes_and_charges_added",
"base_taxes_and_charges_deducted",
@@ -93,13 +81,6 @@
"taxes_and_charges_added",
"taxes_and_charges_deducted",
"total_taxes_and_charges",
- "section_break_44",
- "apply_discount_on",
- "base_discount_amount",
- "additional_discount_account",
- "column_break_46",
- "additional_discount_percentage",
- "discount_amount",
"section_break_49",
"base_grand_total",
"base_rounding_adjustment",
@@ -113,24 +94,57 @@
"total_advance",
"outstanding_amount",
"disable_rounded_total",
+ "section_break_44",
+ "apply_discount_on",
+ "base_discount_amount",
+ "column_break_46",
+ "additional_discount_percentage",
+ "discount_amount",
+ "tax_withheld_vouchers_section",
+ "tax_withheld_vouchers",
+ "sec_tax_breakup",
+ "other_charges_calculation",
+ "pricing_rule_details",
+ "pricing_rules",
+ "raw_materials_supplied",
+ "supplied_items",
+ "payments_tab",
"payments_section",
"mode_of_payment",
- "cash_bank_account",
+ "base_paid_amount",
"clearance_date",
"col_br_payments",
+ "cash_bank_account",
"paid_amount",
- "base_paid_amount",
+ "advances_section",
+ "allocate_advances_automatically",
+ "get_advances",
+ "advances",
+ "advance_tax",
"write_off",
"write_off_amount",
"base_write_off_amount",
"column_break_61",
"write_off_account",
"write_off_cost_center",
- "advances_section",
- "allocate_advances_automatically",
- "get_advances",
- "advances",
- "advance_tax",
+ "address_and_contact_tab",
+ "section_addresses",
+ "supplier_address",
+ "address_display",
+ "col_break_address",
+ "contact_person",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "company_shipping_address_section",
+ "shipping_address",
+ "column_break_126",
+ "shipping_address_display",
+ "company_billing_address_section",
+ "billing_address",
+ "column_break_130",
+ "billing_address_display",
+ "terms_tab",
"payment_schedule_section",
"payment_terms_template",
"ignore_default_payment_terms_template",
@@ -138,23 +152,11 @@
"terms_section_break",
"tc_name",
"terms",
- "printing_settings",
- "letter_head",
- "select_print_heading",
- "column_break_112",
- "group_same_items",
- "language",
- "sb_14",
- "on_hold",
- "release_date",
- "cb_17",
- "hold_comment",
- "more_info",
+ "more_info_tab",
+ "status_section",
"status",
- "inter_company_invoice_reference",
- "represents_company",
- "column_break_147",
- "is_internal_supplier",
+ "column_break_177",
+ "per_received",
"accounting_details_section",
"credit_to",
"party_account_currency",
@@ -162,15 +164,32 @@
"against_expense_account",
"column_break_63",
"unrealized_profit_loss_account",
- "remarks",
"subscription_section",
- "from_date",
- "to_date",
- "column_break_114",
"auto_repeat",
"update_auto_repeat_reference",
- "per_received",
- "is_old_subcontracting_flow"
+ "column_break_114",
+ "from_date",
+ "to_date",
+ "printing_settings",
+ "letter_head",
+ "group_same_items",
+ "column_break_112",
+ "select_print_heading",
+ "language",
+ "sb_14",
+ "on_hold",
+ "release_date",
+ "cb_17",
+ "hold_comment",
+ "additional_info_section",
+ "is_internal_supplier",
+ "represents_company",
+ "column_break_147",
+ "inter_company_invoice_reference",
+ "is_old_subcontracting_flow",
+ "remarks",
+ "connections_tab",
+ "column_break_38"
],
"fields": [
{
@@ -353,7 +372,7 @@
"collapsible_depends_on": "bill_no",
"fieldname": "supplier_invoice_details",
"fieldtype": "Section Break",
- "label": "Supplier Invoice Details"
+ "label": "Supplier Invoice"
},
{
"fieldname": "bill_no",
@@ -376,12 +395,6 @@
"oldfieldtype": "Date",
"print_hide": 1
},
- {
- "depends_on": "return_against",
- "fieldname": "returns",
- "fieldtype": "Section Break",
- "label": "Returns"
- },
{
"depends_on": "return_against",
"fieldname": "return_against",
@@ -393,10 +406,9 @@
"read_only": 1
},
{
- "collapsible": 1,
"fieldname": "section_addresses",
"fieldtype": "Section Break",
- "label": "Address and Contact"
+ "label": "Supplier Address"
},
{
"fieldname": "supplier_address",
@@ -512,17 +524,17 @@
"fieldname": "ignore_pricing_rule",
"fieldtype": "Check",
"label": "Ignore Pricing Rule",
- "no_copy": 1,
"permlevel": 1,
"print_hide": 1
},
{
"fieldname": "sec_warehouse",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Items"
},
{
"depends_on": "update_stock",
- "description": "Sets 'Accepted Warehouse' in each row of the items table.",
"fieldname": "set_warehouse",
"fieldtype": "Link",
"label": "Set Accepted Warehouse",
@@ -531,7 +543,6 @@
},
{
"depends_on": "update_stock",
- "description": "Warehouse where you are maintaining stock of rejected items",
"fieldname": "rejected_warehouse",
"fieldtype": "Link",
"label": "Rejected Warehouse",
@@ -554,6 +565,7 @@
{
"fieldname": "items_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
"oldfieldtype": "Section Break",
"options": "fa fa-shopping-cart"
},
@@ -581,6 +593,7 @@
"reqd": 1
},
{
+ "collapsible": 1,
"fieldname": "pricing_rule_details",
"fieldtype": "Section Break",
"label": "Pricing Rules"
@@ -593,6 +606,7 @@
"read_only": 1
},
{
+ "collapsible": 1,
"collapsible_depends_on": "supplied_items",
"fieldname": "raw_materials_supplied",
"fieldtype": "Section Break",
@@ -656,6 +670,7 @@
"read_only": 1
},
{
+ "depends_on": "total_net_weight",
"fieldname": "total_net_weight",
"fieldtype": "Float",
"label": "Total Net Weight",
@@ -665,6 +680,8 @@
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Taxes and Charges",
"oldfieldtype": "Section Break",
"options": "fa fa-money"
},
@@ -688,7 +705,8 @@
},
{
"fieldname": "section_break_51",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1
},
{
"fieldname": "taxes_and_charges",
@@ -792,7 +810,6 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "discount_amount",
"fieldname": "section_break_44",
"fieldtype": "Section Break",
"label": "Additional Discount"
@@ -832,7 +849,8 @@
},
{
"fieldname": "section_break_49",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "label": "Totals"
},
{
"fieldname": "base_grand_total",
@@ -1003,8 +1021,6 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "write_off_amount",
- "depends_on": "grand_total",
"fieldname": "write_off",
"fieldtype": "Section Break",
"label": "Write Off"
@@ -1081,7 +1097,6 @@
"print_hide": 1
},
{
- "collapsible": 1,
"collapsible_depends_on": "eval:(!doc.is_return)",
"fieldname": "payment_schedule_section",
"fieldtype": "Section Break",
@@ -1102,8 +1117,6 @@
"print_hide": 1
},
{
- "collapsible": 1,
- "collapsible_depends_on": "terms",
"fieldname": "terms_section_break",
"fieldtype": "Section Break",
"label": "Terms and Conditions",
@@ -1119,13 +1132,13 @@
{
"fieldname": "terms",
"fieldtype": "Text Editor",
- "label": "Terms and Conditions1"
+ "label": "Terms and Conditions"
},
{
"collapsible": 1,
"fieldname": "printing_settings",
"fieldtype": "Section Break",
- "label": "Printing Settings"
+ "label": "Print Settings"
},
{
"allow_on_submit": 1,
@@ -1166,15 +1179,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "collapsible": 1,
- "fieldname": "more_info",
- "fieldtype": "Section Break",
- "label": "More Information",
- "oldfieldtype": "Section Break",
- "options": "fa fa-file-text",
- "print_hide": 1
- },
{
"default": "0",
"fetch_from": "supplier.is_internal_supplier",
@@ -1260,7 +1264,7 @@
"collapsible": 1,
"fieldname": "subscription_section",
"fieldtype": "Section Break",
- "label": "Subscription Section",
+ "label": "Subscription",
"print_hide": 1
},
{
@@ -1339,7 +1343,7 @@
},
{
"depends_on": "eval:doc.is_internal_supplier",
- "description": "Unrealized Profit / Loss account for intra-company transfers",
+ "description": "Unrealized Profit/Loss account for intra-company transfers",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
"label": "Unrealized Profit / Loss Account",
@@ -1356,7 +1360,6 @@
},
{
"depends_on": "eval:doc.update_stock && doc.is_internal_supplier",
- "description": "Sets 'From Warehouse' in each row of the items table.",
"fieldname": "set_from_warehouse",
"fieldtype": "Link",
"label": "Set From Warehouse",
@@ -1367,7 +1370,7 @@
"width": "50px"
},
{
- "depends_on": "eval:doc.is_subcontracted",
+ "depends_on": "eval:doc.is_subcontracted",
"fieldname": "supplier_warehouse",
"fieldtype": "Link",
"label": "Supplier Warehouse",
@@ -1386,12 +1389,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "additional_discount_account",
- "fieldtype": "Link",
- "label": "Additional Discount Account",
- "options": "Account"
- },
{
"default": "0",
"fieldname": "ignore_default_payment_terms_template",
@@ -1426,13 +1423,140 @@
"hidden": 1,
"label": "Is Old Subcontracting Flow",
"read_only": 1
- }
+ },
+ {
+ "default": "0",
+ "depends_on": "apply_tds",
+ "fieldname": "tax_withholding_net_total",
+ "fieldtype": "Currency",
+ "hidden": 1,
+ "label": "Tax Withholding Net Total",
+ "no_copy": 1,
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "depends_on": "apply_tds",
+ "fieldname": "base_tax_withholding_net_total",
+ "fieldtype": "Currency",
+ "hidden": 1,
+ "label": "Base Tax Withholding Net Total",
+ "no_copy": 1,
+ "options": "Company:company:default_currency",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "collapsible_depends_on": "tax_withheld_vouchers",
+ "fieldname": "tax_withheld_vouchers_section",
+ "fieldtype": "Section Break",
+ "label": "Tax Withheld Vouchers"
+ },
+ {
+ "fieldname": "tax_withheld_vouchers",
+ "fieldtype": "Table",
+ "label": "Tax Withheld Vouchers",
+ "no_copy": 1,
+ "options": "Tax Withheld Vouchers",
+ "read_only": 1
+ },
+ {
+ "fieldname": "payments_tab",
+ "fieldtype": "Tab Break",
+ "label": "Payments"
+ },
+ {
+ "fieldname": "address_and_contact_tab",
+ "fieldtype": "Tab Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "terms_tab",
+ "fieldtype": "Tab Break",
+ "label": "Terms"
+ },
+ {
+ "fieldname": "more_info_tab",
+ "fieldtype": "Tab Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "column_break_6",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_38",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_50",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_58",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "company_shipping_address_section",
+ "fieldtype": "Section Break",
+ "label": "Company Shipping Address"
+ },
+ {
+ "fieldname": "column_break_126",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "company_billing_address_section",
+ "fieldtype": "Section Break",
+ "label": "Company Billing Address"
+ },
+ {
+ "fieldname": "column_break_130",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "status_section",
+ "fieldtype": "Section Break",
+ "label": "Status"
+ },
+ {
+ "fieldname": "column_break_177",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "additional_info_section",
+ "fieldtype": "Section Break",
+ "label": "Additional Info",
+ "oldfieldtype": "Section Break",
+ "options": "fa fa-file-text",
+ "print_hide": 1
+ },
+ {
+ "fieldname": "incoterm",
+ "fieldtype": "Link",
+ "label": "Incoterm",
+ "options": "Incoterm"
+ },
+ {
+ "depends_on": "incoterm",
+ "fieldname": "named_place",
+ "fieldtype": "Data",
+ "label": "Named Place"
+ }
],
"icon": "fa fa-file-text",
"idx": 204,
"is_submittable": 1,
"links": [],
- "modified": "2022-06-15 15:40:58.527065",
+ "modified": "2023-01-28 19:18:56.586321",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1492,6 +1616,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "supplier",
"title_field": "title",
"track_changes": 1
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index de3927e45b..21addab240 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -5,6 +5,7 @@
import frappe
from frappe import _, throw
from frappe.model.mapper import get_mapped_doc
+from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
@@ -71,6 +72,9 @@ class PurchaseInvoice(BuyingController):
supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category")
self.set_onload("supplier_tds", supplier_tds)
+ if self.is_new():
+ self.set("tax_withheld_vouchers", [])
+
def before_save(self):
if not self.on_hold:
self.release_date = ""
@@ -150,8 +154,8 @@ class PurchaseInvoice(BuyingController):
def set_missing_values(self, for_validate=False):
if not self.credit_to:
self.credit_to = get_party_account("Supplier", self.supplier, self.company)
- self.party_account_currency = frappe.db.get_value(
- "Account", self.credit_to, "account_currency", cache=True
+ self.party_account_currency = frappe.get_cached_value(
+ "Account", self.credit_to, "account_currency"
)
if not self.due_date:
self.due_date = get_due_date(
@@ -172,7 +176,7 @@ class PurchaseInvoice(BuyingController):
if not self.credit_to:
self.raise_missing_debit_credit_account_error("Supplier", self.supplier)
- account = frappe.db.get_value(
+ account = frappe.get_cached_value(
"Account", self.credit_to, ["account_type", "report_type", "account_currency"], as_dict=True
)
@@ -228,7 +232,9 @@ class PurchaseInvoice(BuyingController):
)
if (
- cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate")) and not self.is_return
+ cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate"))
+ and not self.is_return
+ and not self.is_internal_supplier
):
self.validate_rate_with_reference_doc(
[
@@ -575,7 +581,6 @@ class PurchaseInvoice(BuyingController):
self.make_supplier_gl_entry(gl_entries)
self.make_item_gl_entries(gl_entries)
- self.make_discount_gl_entries(gl_entries)
if self.check_asset_cwip_enabled():
self.get_asset_gl_entry(gl_entries)
@@ -604,7 +609,7 @@ class PurchaseInvoice(BuyingController):
def make_supplier_gl_entry(self, gl_entries):
# Checked both rounding_adjustment and rounded_total
- # because rounded_total had value even before introcution of posting GLE based on rounded total
+ # because rounded_total had value even before introduction of posting GLE based on rounded total
grand_total = (
self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total
)
@@ -670,11 +675,8 @@ class PurchaseInvoice(BuyingController):
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
- enable_discount_accounting = cint(
- frappe.db.get_single_value("Buying Settings", "enable_discount_accounting")
- )
provisional_accounting_for_non_stock_items = cint(
- frappe.db.get_value(
+ frappe.get_cached_value(
"Company", self.company, "enable_provisional_accounting_for_non_stock_items"
)
)
@@ -709,6 +711,10 @@ class PurchaseInvoice(BuyingController):
)
)
+ credit_amount = item.base_net_amount
+ if self.is_internal_supplier and item.valuation_rate:
+ credit_amount = flt(item.valuation_rate * item.stock_qty)
+
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
gl_entries.append(
self.get_gl_dict(
@@ -718,7 +724,7 @@ class PurchaseInvoice(BuyingController):
"cost_center": item.cost_center,
"project": item.project or self.project,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "debit": -1 * flt(item.base_net_amount, item.precision("base_net_amount")),
+ "debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
},
warehouse_account[item.from_warehouse]["account_currency"],
item=item,
@@ -806,10 +812,7 @@ class PurchaseInvoice(BuyingController):
else item.deferred_expense_account
)
- if not item.is_fixed_asset:
- dummy, amount = self.get_amount_and_base_amount(item, enable_discount_accounting)
- else:
- amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount"))
+ dummy, amount = self.get_amount_and_base_amount(item, None)
if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
@@ -981,7 +984,7 @@ class PurchaseInvoice(BuyingController):
asset_amount = flt(item.net_amount) + flt(item.item_tax_amount / self.conversion_rate)
base_asset_amount = flt(item.base_net_amount + item.item_tax_amount)
- item_exp_acc_type = frappe.db.get_value("Account", item.expense_account, "account_type")
+ item_exp_acc_type = frappe.get_cached_value("Account", item.expense_account, "account_type")
if not item.expense_account or item_exp_acc_type not in [
"Asset Received But Not Billed",
"Fixed Asset",
@@ -1160,12 +1163,9 @@ class PurchaseInvoice(BuyingController):
def make_tax_gl_entries(self, gl_entries):
# tax table gl entries
valuation_tax = {}
- enable_discount_accounting = cint(
- frappe.db.get_single_value("Buying Settings", "enable_discount_accounting")
- )
for tax in self.get("taxes"):
- amount, base_amount = self.get_tax_amounts(tax, enable_discount_accounting)
+ amount, base_amount = self.get_tax_amounts(tax, None)
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
account_currency = get_account_currency(tax.account_head)
@@ -1250,15 +1250,6 @@ class PurchaseInvoice(BuyingController):
)
)
- @property
- def enable_discount_accounting(self):
- if not hasattr(self, "_enable_discount_accounting"):
- self._enable_discount_accounting = cint(
- frappe.db.get_single_value("Buying Settings", "enable_discount_accounting")
- )
-
- return self._enable_discount_accounting
-
def make_internal_transfer_gl_entries(self, gl_entries):
if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges):
account_currency = get_account_currency(self.unrealized_profit_loss_account)
@@ -1419,14 +1410,17 @@ class PurchaseInvoice(BuyingController):
self.repost_future_sle_and_gle()
self.update_project()
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference)
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
+ "Repost Payment Ledger",
+ "Repost Payment Ledger Items",
"Payment Ledger Entry",
+ "Tax Withheld Vouchers",
)
self.update_advance_tax_references(cancel=1)
@@ -1471,24 +1465,25 @@ class PurchaseInvoice(BuyingController):
def update_billing_status_in_pr(self, update_modified=True):
updated_pr = []
+ po_details = []
+
+ pr_details_billed_amt = self.get_pr_details_billed_amt()
+
for d in self.get("items"):
if d.pr_detail:
- billed_amt = frappe.db.sql(
- """select sum(amount) from `tabPurchase Invoice Item`
- where pr_detail=%s and docstatus=1""",
- d.pr_detail,
- )
- billed_amt = billed_amt and billed_amt[0][0] or 0
frappe.db.set_value(
"Purchase Receipt Item",
d.pr_detail,
"billed_amt",
- billed_amt,
+ flt(pr_details_billed_amt.get(d.pr_detail)),
update_modified=update_modified,
)
updated_pr.append(d.purchase_receipt)
elif d.po_detail:
- updated_pr += update_billed_amount_based_on_po(d.po_detail, update_modified)
+ po_details.append(d.po_detail)
+
+ if po_details:
+ updated_pr += update_billed_amount_based_on_po(po_details, update_modified)
for pr in set(updated_pr):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage
@@ -1496,6 +1491,24 @@ class PurchaseInvoice(BuyingController):
pr_doc = frappe.get_doc("Purchase Receipt", pr)
update_billing_percentage(pr_doc, update_modified=update_modified)
+ def get_pr_details_billed_amt(self):
+ # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice
+
+ pr_details_billed_amt = {}
+ pr_details = [d.get("pr_detail") for d in self.get("items") if d.get("pr_detail")]
+ if pr_details:
+ doctype = frappe.qb.DocType("Purchase Invoice Item")
+ query = (
+ frappe.qb.from_(doctype)
+ .select(doctype.pr_detail, Sum(doctype.amount))
+ .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1)
+ .groupby(doctype.pr_detail)
+ )
+
+ pr_details_billed_amt = frappe._dict(query.run(as_list=1))
+
+ return pr_details_billed_amt
+
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
@@ -1520,7 +1533,7 @@ class PurchaseInvoice(BuyingController):
if not self.tax_withholding_category:
return
- tax_withholding_details, advance_taxes = get_party_tax_withholding_details(
+ tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
self, self.tax_withholding_category
)
@@ -1549,6 +1562,19 @@ class PurchaseInvoice(BuyingController):
for d in to_remove:
self.remove(d)
+ ## Add pending vouchers on which tax was withheld
+ self.set("tax_withheld_vouchers", [])
+
+ for voucher_no, voucher_details in voucher_wise_amount.items():
+ self.append(
+ "tax_withheld_vouchers",
+ {
+ "voucher_name": voucher_no,
+ "voucher_type": voucher_details.get("voucher_type"),
+ "taxable_amount": voucher_details.get("amount"),
+ },
+ )
+
# calculate totals again after applying TDS
self.calculate_taxes_and_totals()
@@ -1791,4 +1817,6 @@ def make_purchase_receipt(source_name, target_doc=None):
target_doc,
)
+ doc.set_onload("ignore_price_list", True)
+
return doc
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
index 82d00308db..e1c37c6001 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
@@ -63,7 +63,7 @@ frappe.listview_settings["Purchase Invoice"] = {
});
listview.page.add_action_item(__("Payment"), ()=>{
- erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment");
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment Entry");
});
}
};
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index e55d3a70af..f901257ccf 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -338,59 +338,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
- @change_settings("Buying Settings", {"enable_discount_accounting": 1})
- def test_purchase_invoice_with_discount_accounting_enabled(self):
-
- discount_account = create_account(
- account_name="Discount Account",
- parent_account="Indirect Expenses - _TC",
- company="_Test Company",
- )
- pi = make_purchase_invoice(discount_account=discount_account, rate=45)
-
- expected_gle = [
- ["_Test Account Cost for Goods Sold - _TC", 250.0, 0.0, nowdate()],
- ["Creditors - _TC", 0.0, 225.0, nowdate()],
- ["Discount Account - _TC", 0.0, 25.0, nowdate()],
- ]
-
- check_gl_entries(self, pi.name, expected_gle, nowdate())
-
- @change_settings("Buying Settings", {"enable_discount_accounting": 1})
- def test_additional_discount_for_purchase_invoice_with_discount_accounting_enabled(self):
-
- additional_discount_account = create_account(
- account_name="Discount Account",
- parent_account="Indirect Expenses - _TC",
- company="_Test Company",
- )
-
- pi = make_purchase_invoice(do_not_save=1, parent_cost_center="Main - _TC")
- pi.apply_discount_on = "Grand Total"
- pi.additional_discount_account = additional_discount_account
- pi.additional_discount_percentage = 10
- pi.disable_rounded_total = 1
- pi.append(
- "taxes",
- {
- "charge_type": "On Net Total",
- "account_head": "_Test Account VAT - _TC",
- "cost_center": "Main - _TC",
- "description": "Test",
- "rate": 10,
- },
- )
- pi.submit()
-
- expected_gle = [
- ["_Test Account Cost for Goods Sold - _TC", 250.0, 0.0, nowdate()],
- ["_Test Account VAT - _TC", 25.0, 0.0, nowdate()],
- ["Creditors - _TC", 0.0, 247.5, nowdate()],
- ["Discount Account - _TC", 0.0, 27.5, nowdate()],
- ]
-
- check_gl_entries(self, pi.name, expected_gle, nowdate())
-
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(test_records[1])
pi.insert()
@@ -1596,6 +1543,37 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi.save()
self.assertEqual(pi.items[0].conversion_factor, 1000)
+ def test_batch_expiry_for_purchase_invoice(self):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ item = self.make_item(
+ "_Test Batch Item For Return Check",
+ {
+ "is_purchase_item": 1,
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBIRC.#####",
+ },
+ )
+
+ pi = make_purchase_invoice(
+ qty=1,
+ item_code=item.name,
+ update_stock=True,
+ )
+
+ pi.load_from_db()
+ batch_no = pi.items[0].batch_no
+ self.assertTrue(batch_no)
+
+ frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(nowdate(), -1))
+
+ return_pi = make_return_doc(pi.doctype, pi.name)
+ return_pi.save().submit()
+
+ self.assertTrue(return_pi.docstatus == 1)
+
def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql(
diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
index 7fa2fe2a66..1fa7e7f3fc 100644
--- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
+++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
@@ -49,6 +49,7 @@
"pricing_rules",
"stock_uom_rate",
"is_free_item",
+ "apply_tds",
"section_break_22",
"net_rate",
"net_amount",
@@ -74,7 +75,6 @@
"manufacturer_part_no",
"accounting",
"expense_account",
- "discount_account",
"col_break5",
"is_fixed_asset",
"asset_location",
@@ -215,6 +215,7 @@
"reqd": 1
},
{
+ "default": "1",
"depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
@@ -712,6 +713,7 @@
"label": "Valuation Rate",
"no_copy": 1,
"options": "Company:company:default_currency",
+ "precision": "6",
"print_hide": 1,
"read_only": 1
},
@@ -820,6 +822,7 @@
},
{
"collapsible": 1,
+ "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
"fieldname": "section_break_26",
"fieldtype": "Section Break",
"label": "Discount and Margin"
@@ -860,24 +863,24 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "discount_account",
- "fieldtype": "Link",
- "label": "Discount Account",
- "options": "Account"
- },
{
"fieldname": "product_bundle",
"fieldtype": "Link",
"label": "Product Bundle",
"options": "Product Bundle",
"read_only": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "apply_tds",
+ "fieldtype": "Check",
+ "label": "Apply TDS"
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-06-17 05:31:10.520171",
+ "modified": "2022-11-29 13:01:20.438217",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_nexus/__init__.py b/erpnext/accounts/doctype/repost_payment_ledger/__init__.py
similarity index 100%
rename from erpnext/erpnext_integrations/doctype/taxjar_nexus/__init__.py
rename to erpnext/accounts/doctype/repost_payment_ledger/__init__.py
diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js
new file mode 100644
index 0000000000..6801408c7b
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js
@@ -0,0 +1,53 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Repost Payment Ledger', {
+ setup: function(frm) {
+ frm.set_query("voucher_type", () => {
+ return {
+ filters: {
+ name: ['in', ['Purchase Invoice', 'Sales Invoice', 'Payment Entry', 'Journal Entry']]
+ }
+ };
+ });
+
+ frm.fields_dict['repost_vouchers'].grid.get_field('voucher_type').get_query = function(doc) {
+ return {
+ filters: {
+ name: ['in', ['Purchase Invoice', 'Sales Invoice', 'Payment Entry', 'Journal Entry']]
+ }
+ }
+ }
+
+ frm.fields_dict['repost_vouchers'].grid.get_field('voucher_no').get_query = function(doc) {
+ if (doc.company) {
+ return {
+ filters: {
+ company: doc.company,
+ docstatus: 1
+ }
+ }
+ }
+ }
+
+ },
+ refresh: function(frm) {
+
+ if (frm.doc.docstatus==1 && ['Queued', 'Failed'].find(x => x == frm.doc.repost_status)) {
+ frm.set_intro(__("Use 'Repost in background' button to trigger background job. Job can only be triggered when document is in Queued or Failed status."));
+ var btn_label = __("Repost in background")
+
+ frm.add_custom_button(btn_label, () => {
+ frappe.call({
+ method: 'erpnext.accounts.doctype.repost_payment_ledger.repost_payment_ledger.execute_repost_payment_ledger',
+ args: {
+ docname: frm.doc.name,
+ }
+ });
+ frappe.msgprint(__('Reposting in the background.'));
+ });
+ }
+
+ }
+});
+
diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json
new file mode 100644
index 0000000000..5175fd169f
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json
@@ -0,0 +1,159 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2022-10-19 21:59:33.553852",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "filters_section",
+ "company",
+ "posting_date",
+ "column_break_4",
+ "voucher_type",
+ "add_manually",
+ "status_section",
+ "repost_status",
+ "repost_error_log",
+ "selected_vouchers_section",
+ "repost_vouchers",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "default": "Today",
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "label": "Posting Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "label": "Voucher Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Repost Payment Ledger",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ },
+ {
+ "fieldname": "selected_vouchers_section",
+ "fieldtype": "Section Break",
+ "label": "Vouchers"
+ },
+ {
+ "fieldname": "filters_section",
+ "fieldtype": "Section Break",
+ "label": "Filters"
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "repost_vouchers",
+ "fieldtype": "Table",
+ "label": "Selected Vouchers",
+ "options": "Repost Payment Ledger Items"
+ },
+ {
+ "fieldname": "repost_status",
+ "fieldtype": "Select",
+ "label": "Repost Status",
+ "options": "\nQueued\nFailed\nCompleted",
+ "read_only": 1
+ },
+ {
+ "fieldname": "status_section",
+ "fieldtype": "Section Break",
+ "label": "Status"
+ },
+ {
+ "default": "0",
+ "description": "Ignore Voucher Type filter and Select Vouchers Manually",
+ "fieldname": "add_manually",
+ "fieldtype": "Check",
+ "label": "Add Manually"
+ },
+ {
+ "depends_on": "eval:doc.repost_error_log",
+ "fieldname": "repost_error_log",
+ "fieldtype": "Long Text",
+ "label": "Repost Error Log"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-11-08 07:38:40.079038",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Repost Payment Ledger",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py
new file mode 100644
index 0000000000..209cad4f90
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py
@@ -0,0 +1,109 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import copy
+
+import frappe
+from frappe import _, qb
+from frappe.model.document import Document
+from frappe.query_builder.custom import ConstantColumn
+
+from erpnext.accounts.utils import _delete_pl_entries, create_payment_ledger_entry
+
+VOUCHER_TYPES = ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]
+
+
+def repost_ple_for_voucher(voucher_type, voucher_no, gle_map=None):
+ if voucher_type and voucher_no and gle_map:
+ _delete_pl_entries(voucher_type, voucher_no)
+ create_payment_ledger_entry(gle_map, cancel=0)
+
+
+@frappe.whitelist()
+def start_payment_ledger_repost(docname=None):
+ """
+ Repost Payment Ledger Entries for Vouchers through Background Job
+ """
+ if docname:
+ repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
+ if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
+ try:
+ for entry in repost_doc.repost_vouchers:
+ doc = frappe.get_doc(entry.voucher_type, entry.voucher_no)
+
+ if doc.doctype in ["Payment Entry", "Journal Entry"]:
+ gle_map = doc.build_gl_map()
+ else:
+ gle_map = doc.get_gl_entries()
+
+ repost_ple_for_voucher(entry.voucher_type, entry.voucher_no, gle_map)
+
+ frappe.db.set_value(repost_doc.doctype, repost_doc.name, "repost_error_log", "")
+ frappe.db.set_value(repost_doc.doctype, repost_doc.name, "repost_status", "Completed")
+ except Exception as e:
+ frappe.db.rollback()
+
+ traceback = frappe.get_traceback()
+ if traceback:
+ message = "Traceback: " + traceback
+ frappe.db.set_value(repost_doc.doctype, repost_doc.name, "repost_error_log", message)
+
+ frappe.db.set_value(repost_doc.doctype, repost_doc.name, "repost_status", "Failed")
+
+
+class RepostPaymentLedger(Document):
+ def __init__(self, *args, **kwargs):
+ super(RepostPaymentLedger, self).__init__(*args, **kwargs)
+ self.vouchers = []
+
+ def before_validate(self):
+ self.load_vouchers_based_on_filters()
+ self.set_status()
+
+ def load_vouchers_based_on_filters(self):
+ if not self.add_manually:
+ self.repost_vouchers.clear()
+ self.get_vouchers()
+ self.extend("repost_vouchers", copy.deepcopy(self.vouchers))
+
+ def get_vouchers(self):
+ self.vouchers.clear()
+
+ filter_on_voucher_types = [self.voucher_type] if self.voucher_type else VOUCHER_TYPES
+
+ for vtype in filter_on_voucher_types:
+ doc = qb.DocType(vtype)
+ doctype_name = ConstantColumn(vtype)
+ query = (
+ qb.from_(doc)
+ .select(doctype_name.as_("voucher_type"), doc.name.as_("voucher_no"))
+ .where(
+ (doc.docstatus == 1)
+ & (doc.company == self.company)
+ & (doc.posting_date.gte(self.posting_date))
+ )
+ )
+ entries = query.run(as_dict=True)
+ self.vouchers.extend(entries)
+
+ def set_status(self):
+ if self.docstatus == 0:
+ self.repost_status = "Queued"
+
+ def on_submit(self):
+ execute_repost_payment_ledger(self.name)
+ frappe.msgprint(_("Repost started in the background"))
+
+
+@frappe.whitelist()
+def execute_repost_payment_ledger(docname):
+ """Repost Payment Ledger Entries by background job."""
+
+ job_name = "payment_ledger_repost_" + docname
+
+ frappe.enqueue(
+ method="erpnext.accounts.doctype.repost_payment_ledger.repost_payment_ledger.start_payment_ledger_repost",
+ docname=docname,
+ is_async=True,
+ job_name=job_name,
+ )
diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger_list.js b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger_list.js
new file mode 100644
index 0000000000..e0451845ce
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger_list.js
@@ -0,0 +1,12 @@
+frappe.listview_settings["Repost Payment Ledger"] = {
+ add_fields: ["repost_status"],
+ get_indicator: function(doc) {
+ var colors = {
+ 'Queued': 'orange',
+ 'Completed': 'green',
+ 'Failed': 'red',
+ };
+ let status = doc.repost_status;
+ return [__(status), colors[status], 'status,=,'+status];
+ },
+};
diff --git a/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py
new file mode 100644
index 0000000000..781726a1e3
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger/test_repost_payment_ledger.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestRepostPaymentLedger(FrappeTestCase):
+ pass
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/__init__.py b/erpnext/accounts/doctype/repost_payment_ledger_items/__init__.py
similarity index 100%
rename from erpnext/erpnext_integrations/doctype/taxjar_settings/__init__.py
rename to erpnext/accounts/doctype/repost_payment_ledger_items/__init__.py
diff --git a/erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.json b/erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.json
new file mode 100644
index 0000000000..93005ee137
--- /dev/null
+++ b/erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.json
@@ -0,0 +1,35 @@
+{
+ "actions": [],
+ "creation": "2022-10-20 10:44:18.796489",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "voucher_type",
+ "voucher_no"
+ ],
+ "fields": [
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "label": "Voucher Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "label": "Voucher No",
+ "options": "voucher_type"
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2022-10-28 14:47:11.838109",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Repost Payment Ledger Items",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py b/erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.py
similarity index 51%
rename from erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py
rename to erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.py
index 7c2689f530..fb19e84f26 100644
--- a/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py
+++ b/erpnext/accounts/doctype/repost_payment_ledger_items/repost_payment_ledger_items.py
@@ -1,9 +1,9 @@
-# Copyright (c) 2021, Havenir Solutions and contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
-class KSAVATSalesAccount(Document):
+class RepostPaymentLedgerItems(Document):
pass
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index ba4cdd606e..47e3f9b935 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -34,7 +34,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
- 'POS Closing Entry', 'Journal Entry', 'Payment Entry'];
+ 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format
@@ -64,6 +64,25 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
this.frm.toggle_reqd("due_date", !this.frm.doc.is_return);
+ if (this.frm.doc.repost_required && this.frm.doc.docstatus===1) {
+ this.frm.set_intro(__("Accounting entries for this invoice needs to be reposted. Please click on 'Repost' button to update."));
+ this.frm.add_custom_button(__('Repost Accounting Entries'),
+ () => {
+ this.frm.call({
+ doc: this.frm.doc,
+ method: 'repost_accounting_entries',
+ freeze: true,
+ freeze_message: __('Reposting...'),
+ callback: (r) => {
+ if (!r.exc) {
+ frappe.msgprint(__('Accounting Entries are reposted'));
+ me.frm.refresh();
+ }
+ }
+ });
+ }).removeClass('btn-default').addClass('btn-warning');
+ }
+
if (this.frm.doc.is_return) {
this.frm.return_print_format = "Sales Invoice Return";
}
@@ -479,9 +498,13 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
is_cash_or_non_trade_discount() {
this.frm.set_df_property("additional_discount_account", "hidden", 1 - this.frm.doc.is_cash_or_non_trade_discount);
+ this.frm.set_df_property("additional_discount_account", "reqd", this.frm.doc.is_cash_or_non_trade_discount);
+
if (!this.frm.doc.is_cash_or_non_trade_discount) {
this.frm.set_value("additional_discount_account", "");
}
+
+ this.calculate_taxes_and_totals();
}
};
@@ -1024,7 +1047,7 @@ var select_loyalty_program = function(frm, loyalty_programs) {
]
});
- dialog.set_primary_action(__("Set"), function() {
+ dialog.set_primary_action(__("Set Loyalty Program"), function() {
dialog.hide();
return frappe.call({
method: "frappe.client.set_value",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index 1c9d3fbfb2..2f4e45e618 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -12,44 +12,29 @@
"customer",
"customer_name",
"tax_id",
- "pos_profile",
- "is_pos",
- "is_consolidated",
- "is_return",
- "is_debit_note",
- "update_billed_amount_in_sales_order",
- "column_break1",
"company",
"company_tax_id",
+ "column_break1",
"posting_date",
"posting_time",
"set_posting_time",
"due_date",
+ "column_break_14",
+ "is_pos",
+ "pos_profile",
+ "is_consolidated",
+ "is_return",
"return_against",
+ "update_billed_amount_in_sales_order",
+ "is_debit_note",
"amended_from",
"accounting_dimensions_section",
- "project",
- "dimension_col_break",
"cost_center",
- "customer_po_details",
- "po_no",
- "column_break_23",
- "po_date",
- "address_and_contact",
- "customer_address",
- "address_display",
- "contact_person",
- "contact_display",
- "contact_mobile",
- "contact_email",
- "territory",
- "col_break4",
- "shipping_address_name",
- "shipping_address",
- "company_address",
- "company_address_display",
- "dispatch_address_name",
- "dispatch_address",
+ "dimension_col_break",
+ "project",
+ "column_break_27",
+ "campaign",
+ "source",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -58,60 +43,37 @@
"price_list_currency",
"plc_conversion_rate",
"ignore_pricing_rule",
- "sec_warehouse",
- "set_warehouse",
- "column_break_55",
- "set_target_warehouse",
"items_section",
- "update_stock",
"scan_barcode",
+ "update_stock",
+ "column_break_39",
+ "set_warehouse",
+ "set_target_warehouse",
+ "section_break_42",
"items",
- "pricing_rule_details",
- "pricing_rules",
- "packing_list",
- "packed_items",
- "product_bundle_help",
- "time_sheet_list",
- "timesheets",
- "total_billing_amount",
- "total_billing_hours",
"section_break_30",
"total_qty",
+ "total_net_weight",
+ "column_break_32",
"base_total",
"base_net_total",
- "column_break_32",
- "total_net_weight",
+ "column_break_52",
"total",
"net_total",
"taxes_section",
+ "tax_category",
"taxes_and_charges",
"column_break_38",
"shipping_rule",
- "tax_category",
+ "column_break_55",
+ "incoterm",
+ "named_place",
"section_break_40",
"taxes",
- "sec_tax_breakup",
- "other_charges_calculation",
"section_break_43",
"base_total_taxes_and_charges",
"column_break_47",
"total_taxes_and_charges",
- "loyalty_points_redemption",
- "loyalty_points",
- "loyalty_amount",
- "redeem_loyalty_points",
- "column_break_77",
- "loyalty_program",
- "loyalty_redemption_account",
- "loyalty_redemption_cost_center",
- "section_break_49",
- "apply_discount_on",
- "is_cash_or_non_trade_discount",
- "base_discount_amount",
- "additional_discount_account",
- "column_break_51",
- "additional_discount_percentage",
- "discount_amount",
"totals",
"base_grand_total",
"base_rounding_adjustment",
@@ -125,21 +87,28 @@
"total_advance",
"outstanding_amount",
"disable_rounded_total",
- "column_break4",
- "write_off_amount",
- "base_write_off_amount",
- "write_off_outstanding_amount_automatically",
- "column_break_74",
- "write_off_account",
- "write_off_cost_center",
- "advances_section",
- "allocate_advances_automatically",
- "get_advances",
- "advances",
- "payment_schedule_section",
- "ignore_default_payment_terms_template",
- "payment_terms_template",
- "payment_schedule",
+ "section_break_49",
+ "apply_discount_on",
+ "base_discount_amount",
+ "is_cash_or_non_trade_discount",
+ "additional_discount_account",
+ "column_break_51",
+ "additional_discount_percentage",
+ "discount_amount",
+ "sec_tax_breakup",
+ "other_charges_calculation",
+ "pricing_rule_details",
+ "pricing_rules",
+ "packing_list",
+ "packed_items",
+ "product_bundle_help",
+ "time_sheet_list",
+ "timesheets",
+ "section_break_104",
+ "total_billing_hours",
+ "column_break_106",
+ "total_billing_amount",
+ "payments_tab",
"payments_section",
"cash_bank_account",
"payments",
@@ -152,47 +121,96 @@
"column_break_90",
"change_amount",
"account_for_change_amount",
+ "advances_section",
+ "allocate_advances_automatically",
+ "get_advances",
+ "advances",
+ "write_off_section",
+ "write_off_amount",
+ "base_write_off_amount",
+ "write_off_outstanding_amount_automatically",
+ "column_break_74",
+ "write_off_account",
+ "write_off_cost_center",
+ "loyalty_points_redemption",
+ "redeem_loyalty_points",
+ "loyalty_points",
+ "loyalty_amount",
+ "column_break_77",
+ "loyalty_program",
+ "loyalty_redemption_account",
+ "loyalty_redemption_cost_center",
+ "contact_and_address_tab",
+ "address_and_contact",
+ "customer_address",
+ "address_display",
+ "col_break4",
+ "contact_person",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "territory",
+ "shipping_address_section",
+ "shipping_address_name",
+ "shipping_address",
+ "shipping_addr_col_break",
+ "dispatch_address_name",
+ "dispatch_address",
+ "company_address_section",
+ "company_address",
+ "company_addr_col_break",
+ "company_address_display",
+ "terms_tab",
+ "payment_schedule_section",
+ "ignore_default_payment_terms_template",
+ "payment_terms_template",
+ "payment_schedule",
"terms_section_break",
"tc_name",
"terms",
- "edit_printing_settings",
- "letter_head",
- "group_same_items",
- "select_print_heading",
- "column_break_84",
- "language",
- "more_information",
- "status",
- "inter_company_invoice_reference",
- "represents_company",
- "customer_group",
- "campaign",
- "col_break23",
- "is_internal_customer",
- "is_discounted",
- "source",
+ "more_info_tab",
+ "customer_po_details",
+ "po_no",
+ "column_break_23",
+ "po_date",
"more_info",
"debit_to",
"party_account_currency",
"is_opening",
"column_break8",
"unrealized_profit_loss_account",
- "remarks",
+ "against_income_account",
"sales_team_section_break",
"sales_partner",
- "column_break10",
"amount_eligible_for_commission",
+ "column_break10",
"commission_rate",
"total_commission",
"section_break2",
"sales_team",
+ "edit_printing_settings",
+ "letter_head",
+ "group_same_items",
+ "column_break_84",
+ "select_print_heading",
+ "language",
"subscription_section",
"from_date",
- "to_date",
- "column_break_140",
"auto_repeat",
+ "column_break_140",
+ "to_date",
"update_auto_repeat_reference",
- "against_income_account"
+ "more_information",
+ "status",
+ "inter_company_invoice_reference",
+ "represents_company",
+ "customer_group",
+ "col_break23",
+ "is_internal_customer",
+ "is_discounted",
+ "remarks",
+ "repost_required",
+ "connections_tab"
],
"fields": [
{
@@ -453,12 +471,11 @@
"label": "Customer's Purchase Order Date"
},
{
- "collapsible": 1,
"fieldname": "address_and_contact",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Address and Contact"
+ "label": "Billing Address"
},
{
"fieldname": "customer_address",
@@ -560,7 +577,6 @@
{
"fieldname": "company_address_display",
"fieldtype": "Small Text",
- "hidden": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Company Address",
@@ -649,16 +665,8 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Ignore Pricing Rule",
- "no_copy": 1,
"print_hide": 1
},
- {
- "fieldname": "sec_warehouse",
- "fieldtype": "Section Break",
- "hide_days": 1,
- "hide_seconds": 1,
- "label": "Warehouse"
- },
{
"depends_on": "update_stock",
"fieldname": "set_warehouse",
@@ -672,6 +680,7 @@
{
"fieldname": "items_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Items",
@@ -703,7 +712,6 @@
"fieldtype": "Table",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Items",
"oldfieldname": "entries",
"oldfieldtype": "Table",
"options": "Sales Invoice Item",
@@ -756,9 +764,10 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.total_billing_amount > 0",
- "depends_on": "eval: !doc.is_return",
+ "depends_on": "eval:!doc.is_return",
"fieldname": "time_sheet_list",
"fieldtype": "Section Break",
+ "hide_border": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Time Sheet List"
@@ -846,6 +855,7 @@
"read_only": 1
},
{
+ "depends_on": "total_net_weight",
"fieldname": "total_net_weight",
"fieldtype": "Float",
"hide_days": 1,
@@ -857,8 +867,10 @@
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
"hide_days": 1,
"hide_seconds": 1,
+ "label": "Taxes and Charges",
"oldfieldtype": "Section Break",
"options": "fa fa-money"
},
@@ -901,6 +913,7 @@
{
"fieldname": "section_break_40",
"fieldtype": "Section Break",
+ "hide_border": 1,
"hide_days": 1,
"hide_seconds": 1
},
@@ -1026,6 +1039,7 @@
"read_only": 1
},
{
+ "allow_on_submit": 1,
"depends_on": "redeem_loyalty_points",
"fieldname": "loyalty_redemption_account",
"fieldtype": "Link",
@@ -1047,7 +1061,6 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "discount_amount",
"fieldname": "section_break_49",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1103,6 +1116,7 @@
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
+ "label": "Totals",
"oldfieldtype": "Section Break",
"options": "fa fa-money",
"print_hide": 1
@@ -1284,8 +1298,6 @@
"print_hide": 1
},
{
- "collapsible": 1,
- "collapsible_depends_on": "eval:(!doc.is_pos && !doc.is_return)",
"fieldname": "payment_schedule_section",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1315,7 +1327,9 @@
"print_hide": 1
},
{
- "depends_on": "eval:doc.is_pos===1||(doc.advances && doc.advances.length>0)",
+ "collapsible": 1,
+ "collapsible_depends_on": "eval:!doc.is_pos",
+ "depends_on": "eval:doc.is_pos===1",
"fieldname": "payments_section",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1324,6 +1338,7 @@
"options": "fa fa-money"
},
{
+ "allow_on_submit": 1,
"depends_on": "is_pos",
"fieldname": "cash_bank_account",
"fieldtype": "Link",
@@ -1353,6 +1368,7 @@
"hide_seconds": 1
},
{
+ "depends_on": "eval: doc.is_pos || doc.redeem_loyalty_points",
"fieldname": "base_paid_amount",
"fieldtype": "Currency",
"hide_days": 1,
@@ -1384,10 +1400,13 @@
"read_only": 1
},
{
+ "collapsible": 1,
+ "depends_on": "is_pos",
"fieldname": "section_break_88",
"fieldtype": "Section Break",
"hide_days": 1,
- "hide_seconds": 1
+ "hide_seconds": 1,
+ "label": "Changes"
},
{
"depends_on": "is_pos",
@@ -1419,6 +1438,7 @@
"print_hide": 1
},
{
+ "allow_on_submit": 1,
"depends_on": "is_pos",
"fieldname": "account_for_change_amount",
"fieldtype": "Link",
@@ -1428,17 +1448,6 @@
"options": "Account",
"print_hide": 1
},
- {
- "collapsible": 1,
- "collapsible_depends_on": "write_off_amount",
- "depends_on": "grand_total",
- "fieldname": "column_break4",
- "fieldtype": "Section Break",
- "hide_days": 1,
- "hide_seconds": 1,
- "label": "Write Off",
- "width": "50%"
- },
{
"fieldname": "write_off_amount",
"fieldtype": "Currency",
@@ -1478,6 +1487,7 @@
"hide_seconds": 1
},
{
+ "allow_on_submit": 1,
"fieldname": "write_off_account",
"fieldtype": "Link",
"hide_days": 1,
@@ -1496,8 +1506,6 @@
"print_hide": 1
},
{
- "collapsible": 1,
- "collapsible_depends_on": "terms",
"fieldname": "terms_section_break",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1531,7 +1539,7 @@
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Printing Settings"
+ "label": "Print Settings"
},
{
"allow_on_submit": 1,
@@ -1592,7 +1600,7 @@
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
- "label": "More Information"
+ "label": "Additional Info"
},
{
"fieldname": "inter_company_invoice_reference",
@@ -1703,6 +1711,7 @@
"read_only": 1
},
{
+ "allow_on_submit": 1,
"default": "No",
"fieldname": "is_opening",
"fieldtype": "Select",
@@ -1767,6 +1776,8 @@
"width": "50%"
},
{
+ "fetch_from": "sales_partner.commission_rate",
+ "fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"hide_days": 1,
@@ -1815,7 +1826,7 @@
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
- "label": "Subscription Section"
+ "label": "Subscription"
},
{
"allow_on_submit": 1,
@@ -1917,6 +1928,7 @@
"read_only": 1
},
{
+ "allow_on_submit": 1,
"depends_on": "eval:doc.is_internal_customer",
"description": "Unrealized Profit / Loss account for intra-company transfers",
"fieldname": "unrealized_profit_loss_account",
@@ -1936,10 +1948,6 @@
"options": "Company",
"read_only": 1
},
- {
- "fieldname": "column_break_55",
- "fieldtype": "Column Break"
- },
{
"depends_on": "eval: doc.is_internal_customer && doc.update_stock",
"fieldname": "set_target_warehouse",
@@ -1963,6 +1971,7 @@
"label": "Disable Rounded Total"
},
{
+ "allow_on_submit": 1,
"fieldname": "additional_discount_account",
"fieldtype": "Link",
"label": "Discount Account",
@@ -2010,6 +2019,118 @@
"fieldname": "is_cash_or_non_trade_discount",
"fieldtype": "Check",
"label": "Is Cash or Non Trade Discount"
+ },
+ {
+ "fieldname": "contact_and_address_tab",
+ "fieldtype": "Tab Break",
+ "label": "Contact & Address"
+ },
+ {
+ "fieldname": "payments_tab",
+ "fieldtype": "Tab Break",
+ "label": "Payments"
+ },
+ {
+ "fieldname": "terms_tab",
+ "fieldtype": "Tab Break",
+ "label": "Terms"
+ },
+ {
+ "fieldname": "more_info_tab",
+ "fieldtype": "Tab Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "column_break_14",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_39",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_42",
+ "fieldtype": "Section Break",
+ "hide_border": 1,
+ "hide_days": 1,
+ "hide_seconds": 1
+ },
+ {
+ "fieldname": "column_break_55",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "shipping_address_section",
+ "fieldtype": "Section Break",
+ "label": "Shipping Address"
+ },
+ {
+ "fieldname": "company_address_section",
+ "fieldtype": "Section Break",
+ "label": "Company Address"
+ },
+ {
+ "fieldname": "shipping_addr_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "company_addr_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_52",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
+ "fieldname": "section_break_104",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_106",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "collapsible_depends_on": "write_off_amount",
+ "depends_on": "is_pos",
+ "fieldname": "write_off_section",
+ "fieldtype": "Section Break",
+ "hide_days": 1,
+ "hide_seconds": 1,
+ "label": "Write Off",
+ "width": "50%"
+ },
+ {
+ "default": "0",
+ "fieldname": "repost_required",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Repost Required",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "incoterm",
+ "fieldtype": "Link",
+ "label": "Incoterm",
+ "options": "Incoterm"
+ },
+ {
+ "depends_on": "incoterm",
+ "fieldname": "named_place",
+ "fieldtype": "Data",
+ "label": "Named Place"
}
],
"icon": "fa fa-file-text",
@@ -2022,7 +2143,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2022-07-11 17:43:56.435382",
+ "modified": "2023-01-28 19:45:47.538163",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index f8c26d1e92..5cda276087 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -7,20 +7,13 @@ from frappe import _, msgprint, throw
from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
-from frappe.utils import (
- add_days,
- add_months,
- cint,
- cstr,
- flt,
- formatdate,
- get_link_to_form,
- getdate,
- nowdate,
-)
+from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_accounting_dimensions,
+)
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,
validate_loyalty_points,
@@ -32,10 +25,12 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.depreciation import (
+ depreciate_asset,
get_disposal_account_and_cost_center,
get_gl_entries_on_asset_disposal,
get_gl_entries_on_asset_regain,
- make_depreciation_entry,
+ reset_depreciation_schedule,
+ reverse_depreciation_entry_made_after_disposal,
)
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
@@ -108,13 +103,11 @@ class SalesInvoice(SellingController):
self.validate_debit_to_acc()
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
self.add_remarks()
- self.validate_write_off_account()
- self.validate_account_for_change_amount()
self.validate_fixed_asset()
self.set_income_account_for_fixed_assets()
self.validate_item_cost_centers()
- self.validate_income_account()
self.check_conversion_rate()
+ self.validate_accounts()
validate_inter_company_party(
self.doctype, self.customer, self.company, self.inter_company_invoice_reference
@@ -178,6 +171,11 @@ class SalesInvoice(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
+ def validate_accounts(self):
+ self.validate_write_off_account()
+ self.validate_account_for_change_amount()
+ self.validate_income_account()
+
def validate_fixed_asset(self):
for d in self.get("items"):
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset:
@@ -186,7 +184,7 @@ class SalesInvoice(SellingController):
if self.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
- elif asset.status in ("Scrapped", "Cancelled") or (
+ elif asset.status in ("Scrapped", "Cancelled", "Capitalized", "Decapitalized") or (
asset.status == "Sold" and not self.is_return
):
frappe.throw(
@@ -375,7 +373,8 @@ class SalesInvoice(SellingController):
if self.update_stock == 1:
self.repost_future_sle_and_gle()
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
+ self.db_set("repost_required", 0)
if (
frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction"
@@ -398,6 +397,8 @@ class SalesInvoice(SellingController):
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
+ "Repost Payment Ledger",
+ "Repost Payment Ledger Items",
"Payment Ledger Entry",
)
@@ -522,6 +523,93 @@ class SalesInvoice(SellingController):
def on_update(self):
self.set_paid_amount()
+ def on_update_after_submit(self):
+ if hasattr(self, "repost_required"):
+ needs_repost = 0
+
+ # Check if any field affecting accounting entry is altered
+ doc_before_update = self.get_doc_before_save()
+ accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"]
+
+ # Check if opening entry check updated
+ if doc_before_update.get("is_opening") != self.is_opening:
+ needs_repost = 1
+
+ if not needs_repost:
+ # Parent Level Accounts excluding party account
+ for field in (
+ "additional_discount_account",
+ "cash_bank_account",
+ "account_for_change_amount",
+ "write_off_account",
+ "loyalty_redemption_account",
+ "unrealized_profit_loss_account",
+ ):
+ if doc_before_update.get(field) != self.get(field):
+ needs_repost = 1
+ break
+
+ # Check for parent accounting dimensions
+ for dimension in accounting_dimensions:
+ if doc_before_update.get(dimension) != self.get(dimension):
+ needs_repost = 1
+ break
+
+ # Check for child tables
+ if self.check_if_child_table_updated(
+ "items",
+ doc_before_update,
+ ("income_account", "expense_account", "discount_account"),
+ accounting_dimensions,
+ ):
+ needs_repost = 1
+
+ if self.check_if_child_table_updated(
+ "taxes", doc_before_update, ("account_head",), accounting_dimensions
+ ):
+ needs_repost = 1
+
+ self.validate_accounts()
+
+ # validate if deferred revenue is enabled for any item
+ # Don't allow to update the invoice if deferred revenue is enabled
+ if needs_repost:
+ for item in self.get("items"):
+ if item.enable_deferred_revenue:
+ frappe.throw(
+ _(
+ "Deferred Revenue is enabled for item {0}. You cannot update the invoice after submission."
+ ).format(item.item_code)
+ )
+
+ self.db_set("repost_required", needs_repost)
+
+ def check_if_child_table_updated(
+ self, child_table, doc_before_update, fields_to_check, accounting_dimensions
+ ):
+ # Check if any field affecting accounting entry is altered
+ for index, item in enumerate(self.get(child_table)):
+ for field in fields_to_check:
+ if doc_before_update.get(child_table)[index].get(field) != item.get(field):
+ return True
+
+ for dimension in accounting_dimensions:
+ if doc_before_update.get(child_table)[index].get(dimension) != item.get(dimension):
+ return True
+
+ return False
+
+ @frappe.whitelist()
+ def repost_accounting_entries(self):
+ if self.repost_required:
+ self.docstatus = 2
+ self.make_gl_entries_on_cancel()
+ self.docstatus = 1
+ self.make_gl_entries()
+ self.db_set("repost_required", 0)
+ else:
+ frappe.throw(_("No updates pending for reposting"))
+
def set_paid_amount(self):
paid_amount = 0.0
base_paid_amount = 0.0
@@ -710,6 +798,7 @@ class SalesInvoice(SellingController):
if (
cint(frappe.db.get_single_value("Selling Settings", "maintain_same_sales_rate"))
and not self.is_return
+ and not self.is_internal_customer
):
self.validate_rate_with_reference_doc(
[["Sales Order", "sales_order", "so_detail"], ["Delivery Note", "delivery_note", "dn_detail"]]
@@ -1033,22 +1122,6 @@ class SalesInvoice(SellingController):
)
)
- if self.apply_discount_on == "Grand Total" and self.get("is_cash_or_discount_account"):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.additional_discount_account,
- "against": self.debit_to,
- "debit": self.base_discount_amount,
- "debit_in_account_currency": self.discount_amount,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.currency,
- item=self,
- )
- )
-
def make_tax_gl_entries(self, gl_entries):
enable_discount_accounting = cint(
frappe.db.get_single_value("Selling Settings", "enable_discount_accounting")
@@ -1107,23 +1180,38 @@ class SalesInvoice(SellingController):
if self.is_return:
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
- asset, item.base_net_amount, item.finance_book
+ asset, item.base_net_amount, item.finance_book, self.get("doctype"), self.get("name")
)
asset.db_set("disposal_date", None)
if asset.calculate_depreciation:
- self.reverse_depreciation_entry_made_after_sale(asset)
- self.reset_depreciation_schedule(asset)
+ posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
+ reverse_depreciation_entry_made_after_disposal(asset, posting_date)
+ notes = _(
+ "This schedule was created when Asset {0} was returned after being sold through Sales Invoice {1}."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name),
+ get_link_to_form(self.doctype, self.get("name")),
+ )
+ reset_depreciation_schedule(asset, self.posting_date, notes)
+ asset.reload()
else:
+ if asset.calculate_depreciation:
+ notes = _(
+ "This schedule was created when Asset {0} was sold through Sales Invoice {1}."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name),
+ get_link_to_form(self.doctype, self.get("name")),
+ )
+ depreciate_asset(asset, self.posting_date, notes)
+ asset.reload()
+
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
- asset, item.base_net_amount, item.finance_book
+ asset, item.base_net_amount, item.finance_book, self.get("doctype"), self.get("name")
)
asset.db_set("disposal_date", self.posting_date)
- if asset.calculate_depreciation:
- self.depreciate_asset(asset)
-
for gle in fixed_asset_gl_entries:
gle["against"] = self.customer
gl_entries.append(self.get_gl_dict(gle, item=item))
@@ -1177,101 +1265,6 @@ class SalesInvoice(SellingController):
self.check_finance_books(item, asset)
return asset
- def check_finance_books(self, item, asset):
- if (
- len(asset.finance_books) > 1 and not item.finance_book and asset.finance_books[0].finance_book
- ):
- frappe.throw(
- _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx)
- )
-
- def depreciate_asset(self, asset):
- asset.flags.ignore_validate_update_after_submit = True
- asset.prepare_depreciation_data(date_of_sale=self.posting_date)
- asset.save()
-
- make_depreciation_entry(asset.name, self.posting_date)
-
- def reset_depreciation_schedule(self, asset):
- asset.flags.ignore_validate_update_after_submit = True
-
- # recreate original depreciation schedule of the asset
- asset.prepare_depreciation_data(date_of_return=self.posting_date)
-
- self.modify_depreciation_schedule_for_asset_repairs(asset)
- asset.save()
-
- def modify_depreciation_schedule_for_asset_repairs(self, asset):
- asset_repairs = frappe.get_all(
- "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"]
- )
-
- for repair in asset_repairs:
- if repair.increase_in_asset_life:
- asset_repair = frappe.get_doc("Asset Repair", repair.name)
- asset_repair.modify_depreciation_schedule()
- asset.prepare_depreciation_data()
-
- def reverse_depreciation_entry_made_after_sale(self, asset):
- from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
-
- posting_date_of_original_invoice = self.get_posting_date_of_sales_invoice()
-
- row = -1
- finance_book = asset.get("schedules")[0].get("finance_book")
- for schedule in asset.get("schedules"):
- if schedule.finance_book != finance_book:
- row = 0
- finance_book = schedule.finance_book
- else:
- row += 1
-
- if schedule.schedule_date == posting_date_of_original_invoice:
- if not self.sale_was_made_on_original_schedule_date(
- asset, schedule, row, posting_date_of_original_invoice
- ) or self.sale_happens_in_the_future(posting_date_of_original_invoice):
-
- reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry)
- reverse_journal_entry.posting_date = nowdate()
- frappe.flags.is_reverse_depr_entry = True
- reverse_journal_entry.submit()
-
- frappe.flags.is_reverse_depr_entry = False
- asset.flags.ignore_validate_update_after_submit = True
- schedule.journal_entry = None
- depreciation_amount = self.get_depreciation_amount_in_je(reverse_journal_entry)
- asset.finance_books[0].value_after_depreciation += depreciation_amount
- asset.save()
-
- def get_posting_date_of_sales_invoice(self):
- return frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
-
- # if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone
- def sale_was_made_on_original_schedule_date(
- self, asset, schedule, row, posting_date_of_original_invoice
- ):
- for finance_book in asset.get("finance_books"):
- if schedule.finance_book == finance_book.finance_book:
- orginal_schedule_date = add_months(
- finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation)
- )
-
- if orginal_schedule_date == posting_date_of_original_invoice:
- return True
- return False
-
- def sale_happens_in_the_future(self, posting_date_of_original_invoice):
- if posting_date_of_original_invoice > getdate():
- return True
-
- return False
-
- def get_depreciation_amount_in_je(self, journal_entry):
- if journal_entry.accounts[0].debit_in_account_currency:
- return journal_entry.accounts[0].debit_in_account_currency
- else:
- return journal_entry.accounts[0].credit_in_account_currency
-
@property
def enable_discount_accounting(self):
if not hasattr(self, "_enable_discount_accounting"):
@@ -1416,7 +1409,11 @@ class SalesInvoice(SellingController):
def make_write_off_gl_entry(self, gl_entries):
# write off entries, applicable if only pos
- if self.write_off_account and flt(self.write_off_amount, self.precision("write_off_amount")):
+ if (
+ self.is_pos
+ and self.write_off_account
+ and flt(self.write_off_amount, self.precision("write_off_amount"))
+ ):
write_off_account_currency = get_account_currency(self.write_off_account)
default_cost_center = frappe.get_cached_value("Company", self.company, "cost_center")
@@ -2103,13 +2100,13 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item"
source_document_warehouse_field = "target_warehouse"
target_document_warehouse_field = "from_warehouse"
+ received_items = get_received_items(source_name, target_doctype, target_detail_field)
else:
source_doc = frappe.get_doc(doctype, source_name)
target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
source_document_warehouse_field = "from_warehouse"
target_document_warehouse_field = "target_warehouse"
-
- received_items = get_received_items(source_name, target_doctype, target_detail_field)
+ received_items = {}
validate_inter_company_transaction(source_doc, doctype)
details = get_inter_company_details(source_doc, doctype)
@@ -2133,6 +2130,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.customer_address
)
+ update_address(
+ target_doc, "billing_address", "billing_address_display", source_doc.customer_address
+ )
if currency:
target_doc.currency = currency
@@ -2177,6 +2177,19 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
def update_item(source, target, source_parent):
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
+ if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item":
+ target.purchase_order = source.parent
+ target.purchase_order_item = source.name
+ target.material_request = source.material_request
+ target.material_request_item = source.material_request_item
+
+ if (
+ source.get("purchase_order")
+ and source.get("purchase_order_item")
+ and target.doctype == "Purchase Invoice Item"
+ ):
+ target.purchase_order = source.purchase_order
+ target.po_detail = source.purchase_order_item
item_field_map = {
"doctype": target_doctype + " Item",
@@ -2203,6 +2216,12 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"serial_no": "serial_no",
}
)
+ elif target_doctype == "Sales Order":
+ item_field_map["field_map"].update(
+ {
+ source_document_warehouse_field: "warehouse",
+ }
+ )
doclist = get_mapped_doc(
doctype,
@@ -2247,6 +2266,7 @@ def get_received_items(reference_name, doctype, reference_fieldname):
def set_purchase_references(doc):
# add internal PO or PR links if any
+
if doc.is_internal_transfer():
if doc.doctype == "Purchase Receipt":
so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference)
@@ -2276,15 +2296,6 @@ def set_purchase_references(doc):
warehouse_map,
)
- if list(so_item_map.values()):
- pd_item_map, parent_child_map, warehouse_map = get_pd_details(
- "Purchase Order Item", so_item_map, "sales_order_item"
- )
-
- update_pi_items(
- doc, "po_detail", "purchase_order", so_item_map, pd_item_map, parent_child_map, warehouse_map
- )
-
def update_pi_items(
doc,
@@ -2300,13 +2311,19 @@ def update_pi_items(
item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item)))
if doc.update_stock:
item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item))
+ if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
+ item.warehouse = frappe.db.get_value(
+ "Purchase Order Item", item.purchase_order_item, "warehouse"
+ )
def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map):
for item in doc.get("items"):
- item.purchase_order_item = purchase_item_map.get(sales_item_map.get(item.delivery_note_item))
item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item))
- item.purchase_order = parent_child_map.get(sales_item_map.get(item.delivery_note_item))
+ if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
+ item.warehouse = frappe.db.get_value(
+ "Purchase Order Item", item.purchase_order_item, "warehouse"
+ )
def get_delivery_note_details(internal_reference):
@@ -2404,7 +2421,7 @@ def get_loyalty_programs(customer):
lp_details = get_loyalty_programs(customer)
if len(lp_details) == 1:
- frappe.db.set(customer, "loyalty_program", lp_details[0])
+ customer.db_set("loyalty_program", lp_details[0])
return lp_details
else:
return lp_details
@@ -2535,7 +2552,6 @@ def create_dunning(source_name, target_doc=None):
target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language")
amounts = calculate_interest_and_amount(
- target.posting_date,
target.outstanding_amount,
target.rate_of_interest,
target.dunning_fee,
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
index 1130284ecc..1605b151a1 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
@@ -29,7 +29,7 @@ frappe.listview_settings['Sales Invoice'] = {
});
listview.page.add_action_item(__("Payment"), ()=>{
- erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment");
+ erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment Entry");
});
}
};
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 782e08e33b..0ffd9463e6 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -8,7 +8,7 @@ import frappe
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.model.naming import make_autoname
from frappe.tests.utils import change_settings
-from frappe.utils import add_days, flt, getdate, nowdate
+from frappe.utils import add_days, flt, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
@@ -21,6 +21,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_comp
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_depr_schedule,
+)
from erpnext.controllers.accounts_controller import update_invoice_status
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
@@ -32,10 +35,20 @@ from erpnext.stock.doctype.stock_entry.test_stock_entry import (
get_qty_after_transaction,
make_stock_entry,
)
-from erpnext.stock.utils import get_incoming_rate
+from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+)
+from erpnext.stock.utils import get_incoming_rate, get_stock_balance
class TestSalesInvoice(unittest.TestCase):
+ def setUp(self):
+ from erpnext.stock.doctype.stock_ledger_entry.test_stock_ledger_entry import create_items
+
+ create_items(["_Test Internal Transfer Item"], uoms=[{"uom": "Box", "conversion_factor": 10}])
+ create_internal_parties()
+ setup_accounts()
+
def make(self):
w = frappe.copy_doc(test_records[0])
w.is_pos = 0
@@ -955,7 +968,8 @@ class TestSalesInvoice(unittest.TestCase):
pos_return.insert()
pos_return.submit()
- self.assertEqual(pos_return.get("payments")[0].amount, -1000)
+ self.assertEqual(pos_return.get("payments")[0].amount, -500)
+ self.assertEqual(pos_return.get("payments")[1].amount, -500)
def test_pos_change_amount(self):
make_pos_profile(
@@ -1155,6 +1169,46 @@ class TestSalesInvoice(unittest.TestCase):
frappe.db.sql("delete from `tabPOS Profile`")
+ def test_bin_details_of_packed_item(self):
+ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ # test Update Items with product bundle
+ if not frappe.db.exists("Item", "_Test Product Bundle Item New"):
+ bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0})
+ bundle_item.append(
+ "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"}
+ )
+ bundle_item.save(ignore_permissions=True)
+
+ make_item("_Packed Item New 1", {"is_stock_item": 1})
+ make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2)
+
+ si = create_sales_invoice(
+ item_code="_Test Product Bundle Item New",
+ update_stock=1,
+ warehouse="_Test Warehouse - _TC",
+ transaction_date=add_days(nowdate(), -1),
+ do_not_submit=1,
+ )
+
+ make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100)
+
+ bin_details = frappe.db.get_value(
+ "Bin",
+ {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"},
+ ["actual_qty", "projected_qty", "ordered_qty"],
+ as_dict=1,
+ )
+
+ si.transaction_date = nowdate()
+ si.save()
+
+ packed_item = si.packed_items[0]
+ self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty))
+ self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty))
+ self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty))
+
def test_pos_si_without_payment(self):
make_pos_profile()
@@ -1705,7 +1759,7 @@ class TestSalesInvoice(unittest.TestCase):
si.save()
self.assertEqual(si.get("items")[0].rate, flt((price_list_rate * 25) / 100 + price_list_rate))
- def test_outstanding_amount_after_advance_jv_cancelation(self):
+ def test_outstanding_amount_after_advance_jv_cancellation(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import (
test_records as jv_test_records,
)
@@ -1749,7 +1803,7 @@ class TestSalesInvoice(unittest.TestCase):
flt(si.rounded_total + si.total_advance, si.precision("outstanding_amount")),
)
- def test_outstanding_amount_after_advance_payment_entry_cancelation(self):
+ def test_outstanding_amount_after_advance_payment_entry_cancellation(self):
pe = frappe.get_doc(
{
"doctype": "Payment Entry",
@@ -2367,29 +2421,6 @@ class TestSalesInvoice(unittest.TestCase):
acc_settings.save()
def test_inter_company_transaction(self):
- from erpnext.selling.doctype.customer.test_customer import create_internal_customer
-
- create_internal_customer(
- customer_name="_Test Internal Customer",
- represents_company="_Test Company 1",
- allowed_to_interact_with="Wind Power LLC",
- )
-
- if not frappe.db.exists("Supplier", "_Test Internal Supplier"):
- supplier = frappe.get_doc(
- {
- "supplier_group": "_Test Supplier Group",
- "supplier_name": "_Test Internal Supplier",
- "doctype": "Supplier",
- "is_internal_supplier": 1,
- "represents_company": "Wind Power LLC",
- }
- )
-
- supplier.append("companies", {"company": "_Test Company 1"})
-
- supplier.insert()
-
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
@@ -2440,38 +2471,6 @@ class TestSalesInvoice(unittest.TestCase):
"Expenses Included In Valuation - _TC1",
)
- if not frappe.db.exists("Customer", "_Test Internal Customer"):
- customer = frappe.get_doc(
- {
- "customer_group": "_Test Customer Group",
- "customer_name": "_Test Internal Customer",
- "customer_type": "Individual",
- "doctype": "Customer",
- "territory": "_Test Territory",
- "is_internal_customer": 1,
- "represents_company": "_Test Company 1",
- }
- )
-
- customer.append("companies", {"company": "Wind Power LLC"})
-
- customer.insert()
-
- if not frappe.db.exists("Supplier", "_Test Internal Supplier"):
- supplier = frappe.get_doc(
- {
- "supplier_group": "_Test Supplier Group",
- "supplier_name": "_Test Internal Supplier",
- "doctype": "Supplier",
- "is_internal_supplier": 1,
- "represents_company": "Wind Power LLC",
- }
- )
-
- supplier.append("companies", {"company": "_Test Company 1"})
-
- supplier.insert()
-
# begin test
si = create_sales_invoice(
company="Wind Power LLC",
@@ -2541,34 +2540,9 @@ class TestSalesInvoice(unittest.TestCase):
se.cancel()
def test_internal_transfer_gl_entry(self):
- ## Create internal transfer account
- from erpnext.selling.doctype.customer.test_customer import create_internal_customer
-
- account = create_account(
- account_name="Unrealized Profit",
- parent_account="Current Liabilities - TCP1",
- company="_Test Company with perpetual inventory",
- )
-
- frappe.db.set_value(
- "Company", "_Test Company with perpetual inventory", "unrealized_profit_loss_account", account
- )
-
- customer = create_internal_customer(
- "_Test Internal Customer 2",
- "_Test Company with perpetual inventory",
- "_Test Company with perpetual inventory",
- )
-
- create_internal_supplier(
- "_Test Internal Supplier 2",
- "_Test Company with perpetual inventory",
- "_Test Company with perpetual inventory",
- )
-
si = create_sales_invoice(
company="_Test Company with perpetual inventory",
- customer=customer,
+ customer="_Test Internal Customer 2",
debit_to="Debtors - TCP1",
warehouse="Stores - TCP1",
income_account="Sales - TCP1",
@@ -2582,7 +2556,7 @@ class TestSalesInvoice(unittest.TestCase):
si.update_stock = 1
si.items[0].target_warehouse = "Work In Progress - TCP1"
- # Add stock to stores for succesful stock transfer
+ # Add stock to stores for successful stock transfer
make_stock_entry(
target="Stores - TCP1", company="_Test Company with perpetual inventory", qty=1, basic_rate=100
)
@@ -2638,6 +2612,77 @@ class TestSalesInvoice(unittest.TestCase):
check_gl_entries(self, target_doc.name, pi_gl_entries, add_days(nowdate(), -1))
+ def test_internal_transfer_gl_precision_issues(self):
+ # Make a stock queue of an item with two valuations
+
+ # Remove all existing stock for this
+ if get_stock_balance("_Test Internal Transfer Item", "Stores - TCP1", "2022-04-10"):
+ create_stock_reconciliation(
+ item_code="_Test Internal Transfer Item",
+ warehouse="Stores - TCP1",
+ qty=0,
+ rate=0,
+ company="_Test Company with perpetual inventory",
+ expense_account="Stock Adjustment - TCP1"
+ if frappe.get_all("Stock Ledger Entry")
+ else "Temporary Opening - TCP1",
+ posting_date="2020-04-10",
+ posting_time="14:00",
+ )
+
+ make_stock_entry(
+ item_code="_Test Internal Transfer Item",
+ target="Stores - TCP1",
+ qty=9000000,
+ basic_rate=52.0,
+ posting_date="2020-04-10",
+ posting_time="14:00",
+ )
+ make_stock_entry(
+ item_code="_Test Internal Transfer Item",
+ target="Stores - TCP1",
+ qty=60000000,
+ basic_rate=52.349777,
+ posting_date="2020-04-10",
+ posting_time="14:00",
+ )
+
+ # Make an internal transfer Sales Invoice Stock in non stock uom to check
+ # for rounding errors while converting to stock uom
+ si = create_sales_invoice(
+ company="_Test Company with perpetual inventory",
+ customer="_Test Internal Customer 2",
+ item_code="_Test Internal Transfer Item",
+ qty=5000000,
+ uom="Box",
+ debit_to="Debtors - TCP1",
+ warehouse="Stores - TCP1",
+ income_account="Sales - TCP1",
+ expense_account="Cost of Goods Sold - TCP1",
+ cost_center="Main - TCP1",
+ currency="INR",
+ do_not_save=1,
+ )
+
+ # Check GL Entries with precision
+ si.update_stock = 1
+ si.items[0].target_warehouse = "Work In Progress - TCP1"
+ si.items[0].conversion_factor = 10
+ si.save()
+ si.submit()
+
+ # Check if adjustment entry is created
+ self.assertTrue(
+ frappe.db.exists(
+ "GL Entry",
+ {
+ "voucher_type": "Sales Invoice",
+ "voucher_no": si.name,
+ "remarks": "Rounding gain/loss Entry for Stock Transfer",
+ },
+ )
+ )
+
def test_item_tax_net_range(self):
item = create_item("T Shirt")
@@ -2727,6 +2772,31 @@ class TestSalesInvoice(unittest.TestCase):
check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1))
+ # Update Invoice post submit and then check GL Entries again
+
+ si.load_from_db()
+ si.items[0].income_account = "Service - _TC"
+ si.additional_discount_account = "_Test Account Sales - _TC"
+ si.taxes[0].account_head = "TDS Payable - _TC"
+ si.save()
+
+ si.load_from_db()
+ self.assertTrue(si.repost_required)
+
+ si.repost_accounting_entries()
+
+ expected_gle = [
+ ["_Test Account Sales - _TC", 22.0, 0.0, nowdate()],
+ ["Debtors - _TC", 88, 0.0, nowdate()],
+ ["Service - _TC", 0.0, 100.0, nowdate()],
+ ["TDS Payable - _TC", 0.0, 10.0, nowdate()],
+ ]
+
+ check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1))
+
+ si.load_from_db()
+ self.assertFalse(si.repost_required)
+
def test_asset_depreciation_on_sale_with_pro_rata(self):
"""
Tests if an Asset set to depreciate yearly on June 30, that gets sold on Sept 30, creates an additional depreciation entry on its date of sale.
@@ -2747,7 +2817,7 @@ class TestSalesInvoice(unittest.TestCase):
["2021-09-30", 5041.1, 26407.22],
]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
@@ -2778,7 +2848,7 @@ class TestSalesInvoice(unittest.TestCase):
expected_values = [["2020-12-31", 30000, 30000], ["2021-12-31", 30000, 60000]]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
@@ -2807,7 +2877,7 @@ class TestSalesInvoice(unittest.TestCase):
["2025-06-06", 18633.88, 100000.0, False],
]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
@@ -3077,7 +3147,7 @@ class TestSalesInvoice(unittest.TestCase):
[deferred_account, 2022.47, 0.0, "2019-03-15"],
]
- gl_entries = gl_entries = frappe.db.sql(
+ gl_entries = frappe.db.sql(
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
@@ -3196,6 +3266,53 @@ class TestSalesInvoice(unittest.TestCase):
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
)
+ def test_batch_expiry_for_sales_invoice_return(self):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ item = make_item(
+ "_Test Batch Item For Return Check",
+ {
+ "is_purchase_item": 1,
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBIRC.#####",
+ },
+ )
+
+ pr = make_purchase_receipt(qty=1, item_code=item.name)
+
+ batch_no = pr.items[0].batch_no
+ si = create_sales_invoice(qty=1, item_code=item.name, update_stock=1, batch_no=batch_no)
+
+ si.load_from_db()
+ batch_no = si.items[0].batch_no
+ self.assertTrue(batch_no)
+
+ frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1))
+
+ return_si = make_return_doc(si.doctype, si.name)
+ return_si.save().submit()
+
+ self.assertTrue(return_si.docstatus == 1)
+
+ def test_sales_invoice_with_payable_tax_account(self):
+ si = create_sales_invoice(do_not_submit=True)
+ si.append(
+ "taxes",
+ {
+ "charge_type": "Actual",
+ "account_head": "Creditors - _TC",
+ "description": "Test",
+ "cost_center": "Main - _TC",
+ "tax_amount": 10,
+ "total": 10,
+ "dont_recompute_tax": 0,
+ },
+ )
+ self.assertRaises(frappe.ValidationError, si.submit)
+
def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill()
@@ -3237,6 +3354,7 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s and posting_date > %s
+ and is_cancelled = 0
order by posting_date asc, account asc""",
(voucher_no, posting_date),
as_dict=1,
@@ -3275,6 +3393,7 @@ def create_sales_invoice(**args):
"item_name": args.item_name or "_Test Item",
"description": args.description or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC",
+ "target_warehouse": args.target_warehouse,
"qty": args.qty or 1,
"uom": args.uom or "Nos",
"stock_uom": args.uom or "Nos",
@@ -3287,8 +3406,9 @@ def create_sales_invoice(**args):
"asset": args.asset or None,
"cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_no": args.serial_no,
- "conversion_factor": 1,
+ "conversion_factor": args.get("conversion_factor", 1),
"incoming_rate": args.incoming_rate or 0,
+ "batch_no": args.batch_no or None,
},
)
@@ -3399,6 +3519,34 @@ def get_taxes_and_charges():
]
+def create_internal_parties():
+ from erpnext.selling.doctype.customer.test_customer import create_internal_customer
+
+ create_internal_customer(
+ customer_name="_Test Internal Customer",
+ represents_company="_Test Company 1",
+ allowed_to_interact_with="Wind Power LLC",
+ )
+
+ create_internal_customer(
+ customer_name="_Test Internal Customer 2",
+ represents_company="_Test Company with perpetual inventory",
+ allowed_to_interact_with="_Test Company with perpetual inventory",
+ )
+
+ create_internal_supplier(
+ supplier_name="_Test Internal Supplier",
+ represents_company="Wind Power LLC",
+ allowed_to_interact_with="_Test Company 1",
+ )
+
+ create_internal_supplier(
+ supplier_name="_Test Internal Supplier 2",
+ represents_company="_Test Company with perpetual inventory",
+ allowed_to_interact_with="_Test Company with perpetual inventory",
+ )
+
+
def create_internal_supplier(supplier_name, represents_company, allowed_to_interact_with):
if not frappe.db.exists("Supplier", supplier_name):
supplier = frappe.get_doc(
@@ -3421,6 +3569,19 @@ def create_internal_supplier(supplier_name, represents_company, allowed_to_inter
return supplier_name
+def setup_accounts():
+ ## Create internal transfer account
+ account = create_account(
+ account_name="Unrealized Profit",
+ parent_account="Current Liabilities - TCP1",
+ company="_Test Company with perpetual inventory",
+ )
+
+ frappe.db.set_value(
+ "Company", "_Test Company with perpetual inventory", "unrealized_profit_loss_account", account
+ )
+
+
def add_taxes(doc):
doc.append(
"taxes",
diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
index b417c7de03..35d19ed843 100644
--- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
+++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
@@ -8,6 +8,7 @@
"engine": "InnoDB",
"field_order": [
"barcode",
+ "has_item_scanned",
"item_code",
"col_break1",
"item_name",
@@ -96,6 +97,10 @@
"delivery_note",
"dn_detail",
"delivered_qty",
+ "internal_transfer_section",
+ "purchase_order",
+ "column_break_92",
+ "purchase_order_item",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -243,6 +248,7 @@
},
{
"collapsible": 1,
+ "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
"fieldname": "discount_and_margin",
"fieldtype": "Section Break",
"label": "Discount and Margin"
@@ -282,7 +288,6 @@
"label": "Discount (%) on Price List Rate with Margin",
"oldfieldname": "adj_rate",
"oldfieldtype": "Float",
- "precision": "2",
"print_hide": 1
},
{
@@ -433,6 +438,7 @@
"label": "Accounting Details"
},
{
+ "allow_on_submit": 1,
"fieldname": "income_account",
"fieldtype": "Link",
"label": "Income Account",
@@ -445,6 +451,7 @@
"width": "120px"
},
{
+ "allow_on_submit": 1,
"fieldname": "expense_account",
"fieldtype": "Link",
"label": "Expense Account",
@@ -464,6 +471,7 @@
"print_hide": 1
},
{
+ "allow_on_submit": 1,
"default": ":Company",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -795,6 +803,7 @@
"options": "Finance Book"
},
{
+ "allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
@@ -829,6 +838,7 @@
"read_only": 1
},
{
+ "allow_on_submit": 1,
"fieldname": "discount_account",
"fieldtype": "Link",
"label": "Discount Account",
@@ -841,12 +851,46 @@
"fieldtype": "Check",
"label": "Grant Commission",
"read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "eval:parent.is_internal_customer == 1",
+ "fieldname": "internal_transfer_section",
+ "fieldtype": "Section Break",
+ "label": "Internal Transfer"
+ },
+ {
+ "fieldname": "purchase_order",
+ "fieldtype": "Link",
+ "label": "Purchase Order",
+ "options": "Purchase Order",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_92",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "purchase_order_item",
+ "fieldtype": "Data",
+ "label": "Purchase Order Item",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "barcode",
+ "fieldname": "has_item_scanned",
+ "fieldtype": "Check",
+ "label": "Has Item Scanned",
+ "read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-06-17 05:33:15.335912",
+ "modified": "2022-12-28 16:17:33.484531",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",
diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json
index 3a871bfced..e236577e11 100644
--- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json
+++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json
@@ -51,6 +51,7 @@
"oldfieldtype": "Data"
},
{
+ "allow_on_submit": 1,
"columns": 2,
"fieldname": "account_head",
"fieldtype": "Link",
@@ -63,6 +64,7 @@
"search_index": 1
},
{
+ "allow_on_submit": 1,
"default": ":Company",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -216,12 +218,13 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-08-05 20:04:01.726867",
+ "modified": "2022-10-17 13:08:17.776528",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
index d9009bae4c..a3a5d627b7 100644
--- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
+++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
@@ -27,7 +27,7 @@ class SalesTaxesandChargesTemplate(Document):
def set_missing_values(self):
for data in self.taxes:
if data.charge_type == "On Net Total" and flt(data.rate) == 0.0:
- data.rate = frappe.db.get_value("Account", data.account_head, "tax_rate")
+ data.rate = frappe.get_cached_value("Account", data.account_head, "tax_rate")
def valdiate_taxes_and_charges_template(doc):
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index 9dab4e91fb..8708342b11 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -280,7 +280,8 @@ class Subscription(Document):
self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval())
self.validate_end_date()
self.validate_to_follow_calendar_months()
- self.cost_center = erpnext.get_default_cost_center(self.get("company"))
+ if not self.cost_center:
+ self.cost_center = erpnext.get_default_cost_center(self.get("company"))
def validate_trial_period(self):
"""
diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js
index 7d6f2aed10..00727f103f 100644
--- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js
+++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js
@@ -5,5 +5,9 @@ frappe.ui.form.on('Subscription Plan', {
price_determination: function(frm) {
frm.toggle_reqd("cost", frm.doc.price_determination === 'Fixed rate');
frm.toggle_reqd("price_list", frm.doc.price_determination === 'Based on price list');
- }
+ },
+
+ subscription_plan: function (frm) {
+ erpnext.utils.check_payments_app();
+ },
});
diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py
index a95e0a9c2d..f3acdc5aa8 100644
--- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py
+++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py
@@ -3,6 +3,7 @@
import frappe
+from dateutil import relativedelta
from frappe import _
from frappe.model.document import Document
from frappe.utils import date_diff, flt, get_first_day, get_last_day, getdate
@@ -49,7 +50,7 @@ def get_plan_rate(
start_date = getdate(start_date)
end_date = getdate(end_date)
- no_of_months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) + 1
+ no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end
diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py
index 4d201292ed..87c5e6d588 100644
--- a/erpnext/accounts/doctype/tax_rule/tax_rule.py
+++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py
@@ -32,7 +32,7 @@ class TaxRule(Document):
def validate(self):
self.validate_tax_template()
- self.validate_date()
+ self.validate_from_to_dates("from_date", "to_date")
self.validate_filters()
self.validate_use_for_shopping_cart()
@@ -51,10 +51,6 @@ class TaxRule(Document):
if not (self.sales_tax_template or self.purchase_tax_template):
frappe.throw(_("Tax Template is mandatory."))
- def validate_date(self):
- if self.from_date and self.to_date and self.from_date > self.to_date:
- frappe.throw(_("From Date cannot be greater than To Date"))
-
def validate_filters(self):
filters = {
"tax_type": self.tax_type,
diff --git a/erpnext/regional/doctype/ksa_vat_purchase_account/__init__.py b/erpnext/accounts/doctype/tax_withheld_vouchers/__init__.py
similarity index 100%
rename from erpnext/regional/doctype/ksa_vat_purchase_account/__init__.py
rename to erpnext/accounts/doctype/tax_withheld_vouchers/__init__.py
diff --git a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json
new file mode 100644
index 0000000000..46b430c659
--- /dev/null
+++ b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json
@@ -0,0 +1,49 @@
+{
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2022-09-13 16:18:59.404842",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "voucher_type",
+ "voucher_name",
+ "taxable_amount"
+ ],
+ "fields": [
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Voucher Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "voucher_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Voucher Name",
+ "options": "voucher_type"
+ },
+ {
+ "fieldname": "taxable_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Taxable Amount",
+ "options": "Company:company:default_currency"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2023-01-13 13:40:41.479208",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Tax Withheld Vouchers",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py
similarity index 52%
rename from erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py
rename to erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py
index 3920bc546c..ea54c5403a 100644
--- a/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py
+++ b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py
@@ -1,9 +1,9 @@
-# Copyright (c) 2021, Havenir Solutions and contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
-class KSAVATPurchaseAccount(Document):
+class TaxWithheldVouchers(Document):
pass
diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
index a519d8be73..f0146ea70e 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
@@ -61,6 +61,9 @@ def get_party_details(inv):
def get_party_tax_withholding_details(inv, tax_withholding_category=None):
+ if inv.doctype == "Payment Entry":
+ inv.tax_withholding_net_total = inv.net_total
+
pan_no = ""
parties = []
party_type, party = get_party_details(inv)
@@ -109,7 +112,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
).format(tax_withholding_category, inv.company, party)
)
- tax_amount, tax_deducted, tax_deducted_on_advances = get_tax_amount(
+ tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount = get_tax_amount(
party_type, parties, inv, tax_details, posting_date, pan_no
)
@@ -118,12 +121,24 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
else:
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
+ cost_center = get_cost_center(inv)
+ tax_row.update({"cost_center": cost_center})
+
if inv.doctype == "Purchase Invoice":
- return tax_row, tax_deducted_on_advances
+ return tax_row, tax_deducted_on_advances, voucher_wise_amount
else:
return tax_row
+def get_cost_center(inv):
+ cost_center = frappe.get_cached_value("Company", inv.company, "cost_center")
+
+ if len(inv.get("taxes", [])) > 0:
+ cost_center = inv.get("taxes")[0].cost_center
+
+ return cost_center
+
+
def get_tax_withholding_details(tax_withholding_category, posting_date, company):
tax_withholding = frappe.get_doc("Tax Withholding Category", tax_withholding_category)
@@ -217,7 +232,9 @@ def get_lower_deduction_certificate(tax_details, pan_no):
def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=None):
- vouchers = get_invoice_vouchers(parties, tax_details, inv.company, party_type=party_type)
+ vouchers, voucher_wise_amount = get_invoice_vouchers(
+ parties, tax_details, inv.company, party_type=party_type
+ )
advance_vouchers = get_advance_vouchers(
parties,
company=inv.company,
@@ -236,16 +253,18 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_deducted = get_deducted_tax(taxable_vouchers, tax_details)
tax_amount = 0
+
if party_type == "Supplier":
ldc = get_lower_deduction_certificate(tax_details, pan_no)
if tax_deducted:
- net_total = inv.net_total
+ net_total = inv.tax_withholding_net_total
if ldc:
- tax_amount = get_tds_amount_from_ldc(
- ldc, parties, pan_no, tax_details, posting_date, net_total
- )
+ tax_amount = get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total)
else:
tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0
+
+ # once tds is deducted, not need to add vouchers in the invoice
+ voucher_wise_amount = {}
else:
tax_amount = get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers)
@@ -259,14 +278,20 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers)
if cint(tax_details.round_off_tax_amount):
- tax_amount = round(tax_amount)
+ tax_amount = normal_round(tax_amount)
- return tax_amount, tax_deducted, tax_deducted_on_advances
+ return tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount
def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
- dr_or_cr = "credit" if party_type == "Supplier" else "debit"
doctype = "Purchase Invoice" if party_type == "Supplier" else "Sales Invoice"
+ field = (
+ "base_tax_withholding_net_total as base_net_total"
+ if party_type == "Supplier"
+ else "base_net_total"
+ )
+ voucher_wise_amount = {}
+ vouchers = []
filters = {
"company": company,
@@ -281,29 +306,40 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"):
{"apply_tds": 1, "tax_withholding_category": tax_details.get("tax_withholding_category")}
)
- invoices = frappe.get_all(doctype, filters=filters, pluck="name") or [""]
+ invoices_details = frappe.get_all(doctype, filters=filters, fields=["name", field])
- journal_entries = frappe.db.sql(
+ for d in invoices_details:
+ vouchers.append(d.name)
+ voucher_wise_amount.update({d.name: {"amount": d.base_net_total, "voucher_type": doctype}})
+
+ journal_entries_details = frappe.db.sql(
"""
- SELECT j.name
+ SELECT j.name, ja.credit - ja.debit AS amount
FROM `tabJournal Entry` j, `tabJournal Entry Account` ja
WHERE
- j.docstatus = 1
+ j.name = ja.parent
+ AND j.docstatus = 1
AND j.is_opening = 'No'
AND j.posting_date between %s and %s
- AND ja.{dr_or_cr} > 0
AND ja.party in %s
- """.format(
- dr_or_cr=dr_or_cr
+ AND j.apply_tds = 1
+ AND j.tax_withholding_category = %s
+ """,
+ (
+ tax_details.from_date,
+ tax_details.to_date,
+ tuple(parties),
+ tax_details.get("tax_withholding_category"),
),
- (tax_details.from_date, tax_details.to_date, tuple(parties)),
- as_list=1,
+ as_dict=1,
)
- if journal_entries:
- journal_entries = journal_entries[0]
+ if journal_entries_details:
+ for d in journal_entries_details:
+ vouchers.append(d.name)
+ voucher_wise_amount.update({d.name: {"amount": d.amount, "voucher_type": "Journal Entry"}})
- return invoices + journal_entries
+ return vouchers, voucher_wise_amount
def get_advance_vouchers(
@@ -318,9 +354,11 @@ def get_advance_vouchers(
"is_cancelled": 0,
"party_type": party_type,
"party": ["in", parties],
- "against_voucher": ["is", "not set"],
}
+ if party_type == "Customer":
+ filters.update({"against_voucher": ["is", "not set"]})
+
if company:
filters["company"] = company
if from_date and to_date:
@@ -330,23 +368,25 @@ def get_advance_vouchers(
def get_taxes_deducted_on_advances_allocated(inv, tax_details):
- advances = [d.reference_name for d in inv.get("advances")]
tax_info = []
- if advances:
- pe = frappe.qb.DocType("Payment Entry").as_("pe")
- at = frappe.qb.DocType("Advance Taxes and Charges").as_("at")
+ if inv.get("advances"):
+ advances = [d.reference_name for d in inv.get("advances")]
- tax_info = (
- frappe.qb.from_(at)
- .inner_join(pe)
- .on(pe.name == at.parent)
- .select(at.parent, at.name, at.tax_amount, at.allocated_amount)
- .where(pe.tax_withholding_category == tax_details.get("tax_withholding_category"))
- .where(at.parent.isin(advances))
- .where(at.account_head == tax_details.account_head)
- .run(as_dict=True)
- )
+ if advances:
+ pe = frappe.qb.DocType("Payment Entry").as_("pe")
+ at = frappe.qb.DocType("Advance Taxes and Charges").as_("at")
+
+ tax_info = (
+ frappe.qb.from_(at)
+ .inner_join(pe)
+ .on(pe.name == at.parent)
+ .select(at.parent, at.name, at.tax_amount, at.allocated_amount)
+ .where(pe.tax_withholding_category == tax_details.get("tax_withholding_category"))
+ .where(at.parent.isin(advances))
+ .where(at.account_head == tax_details.account_head)
+ .run(as_dict=True)
+ )
return tax_info
@@ -370,12 +410,26 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
tds_amount = 0
invoice_filters = {"name": ("in", vouchers), "docstatus": 1, "apply_tds": 1}
- field = "sum(net_total)"
+ ## for TDS to be deducted on advances
+ payment_entry_filters = {
+ "party_type": "Supplier",
+ "party": ("in", parties),
+ "docstatus": 1,
+ "apply_tax_withholding_amount": 1,
+ "unallocated_amount": (">", 0),
+ "posting_date": ["between", (tax_details.from_date, tax_details.to_date)],
+ "tax_withholding_category": tax_details.get("tax_withholding_category"),
+ }
+
+ field = "sum(tax_withholding_net_total)"
if cint(tax_details.consider_party_ledger_amount):
invoice_filters.pop("apply_tds", None)
field = "sum(grand_total)"
+ payment_entry_filters.pop("apply_tax_withholding_amount", None)
+ payment_entry_filters.pop("tax_withholding_category", None)
+
supp_credit_amt = frappe.db.get_value("Purchase Invoice", invoice_filters, field) or 0.0
supp_jv_credit_amt = (
@@ -387,23 +441,32 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
"party": ("in", parties),
"reference_type": ("!=", "Purchase Invoice"),
},
- "sum(credit_in_account_currency)",
+ "sum(credit_in_account_currency - debit_in_account_currency)",
)
or 0.0
)
- supp_credit_amt += supp_jv_credit_amt
- supp_credit_amt += inv.net_total
-
- debit_note_amount = get_debit_note_amount(
- parties, tax_details.from_date, tax_details.to_date, inv.company
+ # Get Amount via payment entry
+ payment_entry_amounts = frappe.db.get_all(
+ "Payment Entry",
+ filters=payment_entry_filters,
+ fields=["sum(unallocated_amount) as amount", "payment_type"],
+ group_by="payment_type",
)
- supp_credit_amt -= debit_note_amount
+
+ supp_credit_amt += supp_jv_credit_amt
+ supp_credit_amt += inv.tax_withholding_net_total
+
+ for type in payment_entry_amounts:
+ if type.payment_type == "Pay":
+ supp_credit_amt += type.amount
+ else:
+ supp_credit_amt -= type.amount
threshold = tax_details.get("threshold", 0)
cumulative_threshold = tax_details.get("cumulative_threshold", 0)
- if (threshold and inv.net_total >= threshold) or (
+ if (threshold and inv.tax_withholding_net_total >= threshold) or (
cumulative_threshold and supp_credit_amt >= cumulative_threshold
):
if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint(
@@ -411,8 +474,11 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
):
# Get net total again as TDS is calculated on net total
# Grand is used to just check for threshold breach
- net_total = frappe.db.get_value("Purchase Invoice", invoice_filters, "sum(net_total)") or 0.0
- net_total += inv.net_total
+ net_total = (
+ frappe.db.get_value("Purchase Invoice", invoice_filters, "sum(tax_withholding_net_total)")
+ or 0.0
+ )
+ net_total += inv.tax_withholding_net_total
supp_credit_amt = net_total - cumulative_threshold
if ldc and is_valid_certificate(
@@ -420,7 +486,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
ldc.valid_upto,
inv.get("posting_date") or inv.get("transaction_date"),
tax_deducted,
- inv.net_total,
+ inv.tax_withholding_net_total,
ldc.certificate_limit,
):
tds_amount = get_ltds_amount(supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details)
@@ -498,12 +564,12 @@ def get_invoice_total_without_tcs(inv, tax_details):
return inv.grand_total - tcs_tax_row_amount
-def get_tds_amount_from_ldc(ldc, parties, pan_no, tax_details, posting_date, net_total):
+def get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total):
tds_amount = 0
limit_consumed = frappe.db.get_value(
"Purchase Invoice",
{"supplier": ("in", parties), "apply_tds": 1, "docstatus": 1},
- "sum(net_total)",
+ "sum(tax_withholding_net_total)",
)
if is_valid_certificate(
@@ -516,22 +582,6 @@ def get_tds_amount_from_ldc(ldc, parties, pan_no, tax_details, posting_date, net
return tds_amount
-def get_debit_note_amount(suppliers, from_date, to_date, company=None):
-
- filters = {
- "supplier": ["in", suppliers],
- "is_return": 1,
- "docstatus": 1,
- "posting_date": ["between", (from_date, to_date)],
- }
- fields = ["abs(sum(net_total)) as net_total"]
-
- if company:
- filters["company"] = company
-
- return frappe.get_all("Purchase Invoice", filters, fields)[0].get("net_total") or 0.0
-
-
def get_ltds_amount(current_amount, deducted_amount, certificate_limit, rate, tax_details):
if current_amount < (certificate_limit - deducted_amount):
return current_amount * rate / 100
@@ -553,3 +603,20 @@ def is_valid_certificate(
valid = True
return valid
+
+
+def normal_round(number):
+ """
+ Rounds a number to the nearest integer.
+ :param number: The number to round.
+ """
+ decimal_part = number - int(number)
+
+ if decimal_part >= 0.5:
+ decimal_part = 1
+ else:
+ decimal_part = 0
+
+ number = int(number) + decimal_part
+
+ return number
diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
index 3059f8d64b..1e86cf5d2e 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
@@ -16,7 +16,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
def setUpClass(self):
# create relevant supplier, etc
create_records()
- create_tax_with_holding_category()
+ create_tax_withholding_category_records()
def tearDown(self):
cancel_invoices()
@@ -38,7 +38,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
pi = create_purchase_invoice(supplier="Test TDS Supplier")
pi.submit()
- # assert equal tax deduction on total invoice amount uptil now
+ # assert equal tax deduction on total invoice amount until now
self.assertEqual(pi.taxes_and_charges_deducted, 3000)
self.assertEqual(pi.grand_total, 7000)
invoices.append(pi)
@@ -47,12 +47,12 @@ class TestTaxWithholdingCategory(unittest.TestCase):
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000)
pi.submit()
- # assert equal tax deduction on total invoice amount uptil now
+ # assert equal tax deduction on total invoice amount until now
self.assertEqual(pi.taxes_and_charges_deducted, 500)
invoices.append(pi)
# delete invoices to avoid clashing
- for d in invoices:
+ for d in reversed(invoices):
d.cancel()
def test_single_threshold_tds(self):
@@ -88,7 +88,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
self.assertEqual(pi.taxes_and_charges_deducted, 1000)
# delete invoices to avoid clashing
- for d in invoices:
+ for d in reversed(invoices):
d.cancel()
def test_tax_withholding_category_checks(self):
@@ -114,7 +114,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
# TDS should be applied only on 1000
self.assertEqual(pi1.taxes[0].tax_amount, 1000)
- for d in invoices:
+ for d in reversed(invoices):
d.cancel()
def test_cumulative_threshold_tcs(self):
@@ -130,7 +130,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
invoices.append(si)
# create another invoice whose total when added to previously created invoice,
- # surpasses cumulative threshhold
+ # surpasses cumulative threshold
si = create_sales_invoice(customer="Test TCS Customer", rate=12000)
si.submit()
@@ -148,8 +148,8 @@ class TestTaxWithholdingCategory(unittest.TestCase):
self.assertEqual(tcs_charged, 500)
invoices.append(si)
- # delete invoices to avoid clashing
- for d in invoices:
+ # cancel invoices to avoid clashing
+ for d in reversed(invoices):
d.cancel()
def test_tds_calculation_on_net_total(self):
@@ -182,8 +182,84 @@ class TestTaxWithholdingCategory(unittest.TestCase):
self.assertEqual(pi1.taxes[0].tax_amount, 4000)
- # delete invoices to avoid clashing
- for d in invoices:
+ # cancel invoices to avoid clashing
+ for d in reversed(invoices):
+ d.cancel()
+
+ def test_tds_calculation_on_net_total_partial_tds(self):
+ frappe.db.set_value(
+ "Supplier", "Test TDS Supplier4", "tax_withholding_category", "Cumulative Threshold TDS"
+ )
+ invoices = []
+
+ pi = create_purchase_invoice(supplier="Test TDS Supplier4", rate=20000, do_not_save=True)
+ pi.extend(
+ "items",
+ [
+ {
+ "doctype": "Purchase Invoice Item",
+ "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"),
+ "qty": 1,
+ "rate": 20000,
+ "cost_center": "Main - _TC",
+ "expense_account": "Stock Received But Not Billed - _TC",
+ "apply_tds": 0,
+ },
+ {
+ "doctype": "Purchase Invoice Item",
+ "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"),
+ "qty": 1,
+ "rate": 35000,
+ "cost_center": "Main - _TC",
+ "expense_account": "Stock Received But Not Billed - _TC",
+ "apply_tds": 1,
+ },
+ ],
+ )
+ pi.save()
+ pi.submit()
+ invoices.append(pi)
+
+ self.assertEqual(pi.taxes[0].tax_amount, 5500)
+
+ # cancel invoices to avoid clashing
+ for d in reversed(invoices):
+ d.cancel()
+
+ orders = []
+
+ po = create_purchase_order(supplier="Test TDS Supplier4", rate=20000, do_not_save=True)
+ po.extend(
+ "items",
+ [
+ {
+ "doctype": "Purchase Order Item",
+ "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"),
+ "qty": 1,
+ "rate": 20000,
+ "cost_center": "Main - _TC",
+ "expense_account": "Stock Received But Not Billed - _TC",
+ "apply_tds": 0,
+ },
+ {
+ "doctype": "Purchase Order Item",
+ "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"),
+ "qty": 1,
+ "rate": 35000,
+ "cost_center": "Main - _TC",
+ "expense_account": "Stock Received But Not Billed - _TC",
+ "apply_tds": 1,
+ },
+ ],
+ )
+ po.save()
+ po.submit()
+ orders.append(po)
+
+ self.assertEqual(po.taxes[0].tax_amount, 5500)
+
+ # cancel orders to avoid clashing
+ for d in reversed(orders):
d.cancel()
def test_multi_category_single_supplier(self):
@@ -207,10 +283,84 @@ class TestTaxWithholdingCategory(unittest.TestCase):
self.assertEqual(pi1.taxes[0].tax_amount, 250)
- # delete invoices to avoid clashing
- for d in invoices:
+ # cancel invoices to avoid clashing
+ for d in reversed(invoices):
d.cancel()
+ def test_tax_withholding_category_voucher_display(self):
+ frappe.db.set_value(
+ "Supplier", "Test TDS Supplier6", "tax_withholding_category", "Test Multi Invoice Category"
+ )
+ invoices = []
+
+ pi = create_purchase_invoice(supplier="Test TDS Supplier6", rate=4000, do_not_save=True)
+ pi.apply_tds = 1
+ pi.tax_withholding_category = "Test Multi Invoice Category"
+ pi.save()
+ pi.submit()
+ invoices.append(pi)
+
+ pi1 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=2000, do_not_save=True)
+ pi1.apply_tds = 1
+ pi1.is_return = 1
+ pi1.items[0].qty = -1
+ pi1.tax_withholding_category = "Test Multi Invoice Category"
+ pi1.save()
+ pi1.submit()
+ invoices.append(pi1)
+
+ pi2 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=9000, do_not_save=True)
+ pi2.apply_tds = 1
+ pi2.tax_withholding_category = "Test Multi Invoice Category"
+ pi2.save()
+ pi2.submit()
+ invoices.append(pi2)
+
+ pi2.load_from_db()
+
+ self.assertTrue(pi2.taxes[0].tax_amount, 1100)
+
+ self.assertTrue(pi2.tax_withheld_vouchers[0].voucher_name == pi1.name)
+ self.assertTrue(pi2.tax_withheld_vouchers[0].taxable_amount == pi1.net_total)
+ self.assertTrue(pi2.tax_withheld_vouchers[1].voucher_name == pi.name)
+ self.assertTrue(pi2.tax_withheld_vouchers[1].taxable_amount == pi.net_total)
+
+ # cancel invoices to avoid clashing
+ for d in reversed(invoices):
+ d.cancel()
+
+ def test_tax_withholding_via_payment_entry_for_advances(self):
+ frappe.db.set_value(
+ "Supplier", "Test TDS Supplier7", "tax_withholding_category", "Advance TDS Category"
+ )
+
+ # create payment entry
+ pe1 = create_payment_entry(
+ payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000
+ )
+ pe1.submit()
+
+ self.assertFalse(pe1.get("taxes"))
+
+ pe2 = create_payment_entry(
+ payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000
+ )
+ pe2.submit()
+
+ self.assertFalse(pe2.get("taxes"))
+
+ pe3 = create_payment_entry(
+ payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000
+ )
+ pe3.apply_tax_withholding_amount = 1
+ pe3.save()
+ pe3.submit()
+
+ self.assertEquals(pe3.get("taxes")[0].tax_amount, 1200)
+ pe1.cancel()
+ pe2.cancel()
+ pe3.cancel()
+
def cancel_invoices():
purchase_invoices = frappe.get_all(
@@ -266,6 +416,39 @@ def create_purchase_invoice(**args):
return pi
+def create_purchase_order(**args):
+ # return purchase order doc object
+ item = frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name")
+
+ args = frappe._dict(args)
+ po = frappe.get_doc(
+ {
+ "doctype": "Purchase Order",
+ "transaction_date": today(),
+ "schedule_date": today(),
+ "apply_tds": 0 if args.do_not_apply_tds else 1,
+ "supplier": args.supplier,
+ "company": "_Test Company",
+ "taxes_and_charges": "",
+ "currency": "INR",
+ "taxes": [],
+ "items": [
+ {
+ "doctype": "Purchase Order Item",
+ "item_code": item,
+ "qty": args.qty or 1,
+ "rate": args.rate or 10000,
+ "cost_center": "Main - _TC",
+ "expense_account": "Stock Received But Not Billed - _TC",
+ }
+ ],
+ }
+ )
+
+ po.save()
+ return po
+
+
def create_sales_invoice(**args):
# return sales invoice doc object
item = frappe.db.get_value("Item", {"item_name": "TCS Item"}, "name")
@@ -299,6 +482,32 @@ def create_sales_invoice(**args):
return si
+def create_payment_entry(**args):
+ # return payment entry doc object
+ args = frappe._dict(args)
+ pe = frappe.get_doc(
+ {
+ "doctype": "Payment Entry",
+ "posting_date": today(),
+ "payment_type": args.payment_type,
+ "party_type": args.party_type,
+ "party": args.party,
+ "company": "_Test Company",
+ "paid_from": "Cash - _TC",
+ "paid_to": "Creditors - _TC",
+ "paid_amount": args.paid_amount or 10000,
+ "received_amount": args.paid_amount or 10000,
+ "reference_no": args.reference_no or "12345",
+ "reference_date": today(),
+ "paid_from_account_currency": "INR",
+ "paid_to_account_currency": "INR",
+ }
+ )
+
+ pe.save()
+ return pe
+
+
def create_records():
# create a new suppliers
for name in [
@@ -308,6 +517,8 @@ def create_records():
"Test TDS Supplier3",
"Test TDS Supplier4",
"Test TDS Supplier5",
+ "Test TDS Supplier6",
+ "Test TDS Supplier7",
]:
if frappe.db.exists("Supplier", name):
continue
@@ -378,123 +589,129 @@ def create_records():
).insert()
-def create_tax_with_holding_category():
+def create_tax_withholding_category_records():
fiscal_year = get_fiscal_year(today(), company="_Test Company")
+ from_date = fiscal_year[1]
+ to_date = fiscal_year[2]
+
# Cumulative threshold
- if not frappe.db.exists("Tax Withholding Category", "Cumulative Threshold TDS"):
- frappe.get_doc(
- {
- "doctype": "Tax Withholding Category",
- "name": "Cumulative Threshold TDS",
- "category_name": "10% TDS",
- "rates": [
- {
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 0,
- "cumulative_threshold": 30000.00,
- }
- ],
- "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}],
- }
- ).insert()
+ create_tax_withholding_category(
+ category_name="Cumulative Threshold TDS",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=0,
+ cumulative_threshold=30000.00,
+ )
- if not frappe.db.exists("Tax Withholding Category", "Cumulative Threshold TCS"):
- frappe.get_doc(
- {
- "doctype": "Tax Withholding Category",
- "name": "Cumulative Threshold TCS",
- "category_name": "10% TCS",
- "rates": [
- {
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 0,
- "cumulative_threshold": 30000.00,
- }
- ],
- "accounts": [{"company": "_Test Company", "account": "TCS - _TC"}],
- }
- ).insert()
+ # Category for TCS
+ create_tax_withholding_category(
+ category_name="Cumulative Threshold TCS",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TCS - _TC",
+ single_threshold=0,
+ cumulative_threshold=30000.00,
+ )
- # Single thresold
- if not frappe.db.exists("Tax Withholding Category", "Single Threshold TDS"):
- frappe.get_doc(
- {
- "doctype": "Tax Withholding Category",
- "name": "Single Threshold TDS",
- "category_name": "10% TDS",
- "rates": [
- {
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 20000.00,
- "cumulative_threshold": 0,
- }
- ],
- "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}],
- }
- ).insert()
+ # Single threshold
+ create_tax_withholding_category(
+ category_name="Single Threshold TDS",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=20000,
+ cumulative_threshold=0,
+ )
- if not frappe.db.exists("Tax Withholding Category", "New TDS Category"):
- frappe.get_doc(
- {
- "doctype": "Tax Withholding Category",
- "name": "New TDS Category",
- "category_name": "New TDS Category",
- "round_off_tax_amount": 1,
- "consider_party_ledger_amount": 1,
- "tax_on_excess_amount": 1,
- "rates": [
- {
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 0,
- "cumulative_threshold": 30000,
- }
- ],
- "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}],
- }
- ).insert()
+ create_tax_withholding_category(
+ category_name="New TDS Category",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=0,
+ cumulative_threshold=30000,
+ round_off_tax_amount=1,
+ consider_party_ledger_amount=1,
+ tax_on_excess_amount=1,
+ )
- if not frappe.db.exists("Tax Withholding Category", "Test Service Category"):
- frappe.get_doc(
- {
- "doctype": "Tax Withholding Category",
- "name": "Test Service Category",
- "category_name": "Test Service Category",
- "rates": [
- {
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 2000,
- "cumulative_threshold": 2000,
- }
- ],
- "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}],
- }
- ).insert()
+ create_tax_withholding_category(
+ category_name="Test Service Category",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=2000,
+ cumulative_threshold=2000,
+ )
- if not frappe.db.exists("Tax Withholding Category", "Test Goods Category"):
+ create_tax_withholding_category(
+ category_name="Test Goods Category",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=2000,
+ cumulative_threshold=2000,
+ )
+
+ create_tax_withholding_category(
+ category_name="Test Multi Invoice Category",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=5000,
+ cumulative_threshold=10000,
+ )
+
+ create_tax_withholding_category(
+ category_name="Advance TDS Category",
+ rate=10,
+ from_date=from_date,
+ to_date=to_date,
+ account="TDS - _TC",
+ single_threshold=5000,
+ cumulative_threshold=10000,
+ consider_party_ledger_amount=1,
+ )
+
+
+def create_tax_withholding_category(
+ category_name,
+ rate,
+ from_date,
+ to_date,
+ account,
+ single_threshold=0,
+ cumulative_threshold=0,
+ round_off_tax_amount=0,
+ consider_party_ledger_amount=0,
+ tax_on_excess_amount=0,
+):
+ if not frappe.db.exists("Tax Withholding Category", category_name):
frappe.get_doc(
{
"doctype": "Tax Withholding Category",
- "name": "Test Goods Category",
- "category_name": "Test Goods Category",
+ "name": category_name,
+ "category_name": category_name,
+ "round_off_tax_amount": round_off_tax_amount,
+ "consider_party_ledger_amount": consider_party_ledger_amount,
+ "tax_on_excess_amount": tax_on_excess_amount,
"rates": [
{
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "tax_withholding_rate": 10,
- "single_threshold": 2000,
- "cumulative_threshold": 2000,
+ "from_date": from_date,
+ "to_date": to_date,
+ "tax_withholding_rate": rate,
+ "single_threshold": single_threshold,
+ "cumulative_threshold": cumulative_threshold,
}
],
- "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}],
+ "accounts": [{"company": "_Test Company", "account": account}],
}
).insert()
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 16072f331f..41fdb6a97f 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -128,6 +128,12 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
new_gl_map = []
for d in gl_map:
cost_center = d.get("cost_center")
+
+ # Validate budget against main cost center
+ validate_expense_against_budget(
+ d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
+ )
+
if cost_center and cost_center_allocation.get(cost_center):
for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
gle = copy.deepcopy(d)
@@ -193,7 +199,14 @@ def merge_similar_entries(gl_map, precision=None):
# filter zero debit and credit entries
merged_gl_map = filter(
- lambda x: flt(x.debit, precision) != 0 or flt(x.credit, precision) != 0, merged_gl_map
+ lambda x: flt(x.debit, precision) != 0
+ or flt(x.credit, precision) != 0
+ or (
+ x.voucher_type == "Journal Entry"
+ and frappe.get_cached_value("Journal Entry", x.voucher_no, "voucher_type")
+ == "Exchange Gain Or Loss"
+ ),
+ merged_gl_map,
)
merged_gl_map = list(merged_gl_map)
@@ -344,15 +357,26 @@ def process_debit_credit_difference(gl_map):
allowance = get_debit_credit_allowance(voucher_type, precision)
debit_credit_diff = get_debit_credit_difference(gl_map, precision)
+
if abs(debit_credit_diff) > allowance:
- raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
+ if not (
+ voucher_type == "Journal Entry"
+ and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type")
+ == "Exchange Gain Or Loss"
+ ):
+ raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
elif abs(debit_credit_diff) >= (1.0 / (10**precision)):
make_round_off_gle(gl_map, debit_credit_diff, precision)
debit_credit_diff = get_debit_credit_difference(gl_map, precision)
if abs(debit_credit_diff) > allowance:
- raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
+ if not (
+ voucher_type == "Journal Entry"
+ and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type")
+ == "Exchange Gain Or Loss"
+ ):
+ raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
def get_debit_credit_difference(gl_map, precision):
@@ -388,20 +412,22 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
gl_map[0].company, gl_map[0].voucher_type, gl_map[0].voucher_no
)
- round_off_account_exists = False
round_off_gle = frappe._dict()
- for d in gl_map:
- if d.account == round_off_account:
- round_off_gle = d
- if d.debit:
- debit_credit_diff -= flt(d.debit)
- else:
- debit_credit_diff += flt(d.credit)
- round_off_account_exists = True
+ round_off_account_exists = False
- if round_off_account_exists and abs(debit_credit_diff) < (1.0 / (10**precision)):
- gl_map.remove(round_off_gle)
- return
+ if gl_map[0].voucher_type != "Period Closing Voucher":
+ for d in gl_map:
+ if d.account == round_off_account:
+ round_off_gle = d
+ if d.debit:
+ debit_credit_diff -= flt(d.debit) - flt(d.credit)
+ else:
+ debit_credit_diff += flt(d.credit)
+ round_off_account_exists = True
+
+ if round_off_account_exists and abs(debit_credit_diff) < (1.0 / (10**precision)):
+ gl_map.remove(round_off_gle)
+ return
if not round_off_gle:
for k in ["voucher_type", "voucher_no", "company", "posting_date", "remarks"]:
@@ -424,7 +450,6 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision):
)
update_accounting_dimensions(round_off_gle)
-
if not round_off_account_exists:
gl_map.append(round_off_gle)
@@ -489,7 +514,6 @@ def make_reverse_gl_entries(
).run(as_dict=1)
if gl_entries:
- create_payment_ledger_entry(gl_entries, cancel=1)
create_payment_ledger_entry(
gl_entries, cancel=1, adv_adj=adv_adj, update_outstanding=update_outstanding
)
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index e39f22b4cf..01cfb58dec 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -207,11 +207,17 @@ def set_address_details(
)
if company_address:
- party_details.update({"company_address": company_address})
+ party_details.company_address = company_address
else:
party_details.update(get_company_address(company))
- if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order", "Quotation"]:
+ if doctype and doctype in [
+ "Delivery Note",
+ "Sales Invoice",
+ "Sales Order",
+ "Quotation",
+ "POS Invoice",
+ ]:
if party_details.company_address:
party_details.update(
get_fetch_values(doctype, "company_address", party_details.company_address)
@@ -219,12 +225,31 @@ def set_address_details(
get_regional_address_details(party_details, doctype, company)
elif doctype and doctype in ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]:
- if party_details.company_address:
- party_details["shipping_address"] = shipping_address or party_details["company_address"]
- party_details.shipping_address_display = get_address_display(party_details["shipping_address"])
+ if shipping_address:
party_details.update(
- get_fetch_values(doctype, "shipping_address", party_details.shipping_address)
+ shipping_address=shipping_address,
+ shipping_address_display=get_address_display(shipping_address),
+ **get_fetch_values(doctype, "shipping_address", shipping_address)
)
+
+ if party_details.company_address:
+ # billing address
+ party_details.update(
+ billing_address=party_details.company_address,
+ billing_address_display=(
+ party_details.company_address_display or get_address_display(party_details.company_address)
+ ),
+ **get_fetch_values(doctype, "billing_address", party_details.company_address)
+ )
+
+ # shipping address - if not already set
+ if not party_details.shipping_address:
+ party_details.update(
+ shipping_address=party_details.billing_address,
+ shipping_address_display=party_details.billing_address_display,
+ **get_fetch_values(doctype, "shipping_address", party_details.billing_address)
+ )
+
get_regional_address_details(party_details, doctype, company)
return party_details.get(billing_address_field), party_details.shipping_address_name
@@ -277,7 +302,7 @@ def get_default_price_list(party):
return party.default_price_list
if party.doctype == "Customer":
- return frappe.db.get_value("Customer Group", party.customer_group, "default_price_list")
+ return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
@@ -366,7 +391,7 @@ def get_party_account(party_type, party=None, company=None):
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
- account_currency = frappe.db.get_value("Account", account, "account_currency", cache=True)
+ account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
@@ -383,7 +408,7 @@ def get_party_bank_account(party_type, party):
def get_party_account_currency(party_type, party, company):
def generator():
party_account = get_party_account(party_type, party, company)
- return frappe.db.get_value("Account", party_account, "account_currency", cache=True)
+ return frappe.get_cached_value("Account", party_account, "account_currency")
return frappe.local_cache("party_account_currency", (party_type, party, company), generator)
@@ -455,15 +480,15 @@ def validate_party_accounts(doc):
else:
companies.append(account.company)
- party_account_currency = frappe.db.get_value(
- "Account", account.account, "account_currency", cache=True
- )
+ party_account_currency = frappe.get_cached_value("Account", account.account, "account_currency")
if frappe.db.get_default("Company"):
company_default_currency = frappe.get_cached_value(
"Company", frappe.db.get_default("Company"), "default_currency"
)
else:
- company_default_currency = frappe.db.get_value("Company", account.company, "default_currency")
+ company_default_currency = frappe.get_cached_value(
+ "Company", account.company, "default_currency"
+ )
validate_party_gle_currency(doc.doctype, doc.name, account.company, party_account_currency)
@@ -525,7 +550,7 @@ def get_due_date_from_template(template_name, posting_date, bill_date):
elif term.due_date_based_on == "Day(s) after the end of the invoice month":
due_date = max(due_date, add_days(get_last_day(due_date), term.credit_days))
else:
- due_date = max(due_date, add_months(get_last_day(due_date), term.credit_months))
+ due_date = max(due_date, get_last_day(add_months(due_date, term.credit_months)))
return due_date
@@ -782,7 +807,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
)
for d in companies:
- company_default_currency = frappe.db.get_value("Company", d.company, "default_currency")
+ company_default_currency = frappe.get_cached_value("Company", d.company, "default_currency")
party_account_currency = get_party_account_currency(party_type, party, d.company)
if party_account_currency == company_default_currency:
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js
index 7cf14e6738..e1a30a4b77 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.js
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js
@@ -51,6 +51,8 @@ frappe.query_reports["Accounts Payable"] = {
} else {
frappe.query_report.set_filter_value('tax_id', "");
}
+
+ frappe.query_report.refresh();
}
},
{
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index 0238711a70..0b4e577f6c 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -178,6 +178,11 @@ frappe.query_reports["Accounts Receivable"] = {
"fieldtype": "Data",
"hidden": 1
},
+ {
+ "fieldname": "show_remarks",
+ "label": __("Show Remarks"),
+ "fieldtype": "Check",
+ },
{
"fieldname": "customer_name",
"label": __("Customer Name"),
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index c7c746bede..94a1510f09 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -99,6 +99,9 @@ class ReceivablePayableReport(object):
# Get return entries
self.get_return_entries()
+ # Get Exchange Rate Revaluations
+ self.get_exchange_rate_revaluations()
+
self.data = []
for ple in self.ple_entries:
@@ -119,6 +122,7 @@ class ReceivablePayableReport(object):
party_account=ple.account,
posting_date=ple.posting_date,
account_currency=ple.account_currency,
+ remarks=ple.remarks,
invoiced=0.0,
paid=0.0,
credit_note=0.0,
@@ -165,7 +169,7 @@ class ReceivablePayableReport(object):
"range4",
"range5",
"future_amount",
- "remaining_balance"
+ "remaining_balance",
]
def get_voucher_balance(self, ple):
@@ -178,6 +182,11 @@ class ReceivablePayableReport(object):
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
row = self.voucher_balance.get(key)
+
+ if not row:
+ # no invoice, this is an invoice / stand-alone payment / credit note
+ row = self.voucher_balance.get((ple.voucher_type, ple.voucher_no, ple.party))
+
return row
def update_voucher_balance(self, ple):
@@ -187,7 +196,11 @@ class ReceivablePayableReport(object):
if not row:
return
- amount = ple.amount
+ # amount in "Party Currency", if its supplied. If not, amount in company currency
+ if self.filters.get(scrub(self.party_type)):
+ amount = ple.amount_in_account_currency
+ else:
+ amount = ple.amount
amount_in_account_currency = ple.amount_in_account_currency
# update voucher
@@ -241,7 +254,8 @@ class ReceivablePayableReport(object):
row.invoice_grand_total = row.invoiced
if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) and (
- abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision
+ (abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision)
+ or (row.voucher_no in self.err_journals)
):
# non-zero oustanding, we must consider this row
@@ -685,9 +699,10 @@ class ReceivablePayableReport(object):
ple.party,
ple.posting_date,
ple.due_date,
- ple.account_currency.as_("currency"),
+ ple.account_currency,
ple.amount,
ple.amount_in_account_currency,
+ ple.remarks,
)
.where(ple.delinked == 0)
.where(Criterion.all(self.qb_selection_filter))
@@ -722,6 +737,7 @@ class ReceivablePayableReport(object):
def prepare_conditions(self):
self.qb_selection_filter = []
party_type_field = scrub(self.party_type)
+ self.qb_selection_filter.append(self.ple.party_type == self.party_type)
self.add_common_filters(party_type_field=party_type_field)
@@ -736,7 +752,7 @@ class ReceivablePayableReport(object):
self.add_accounting_dimensions_filters()
- def get_cost_center_conditions(self, conditions):
+ def get_cost_center_conditions(self):
lft, rgt = frappe.db.get_value("Cost Center", self.filters.cost_center, ["lft", "rgt"])
cost_center_list = [
center.name
@@ -772,7 +788,7 @@ class ReceivablePayableReport(object):
def add_customer_filters(
self,
):
- self.customter = qb.DocType("Customer")
+ self.customer = qb.DocType("Customer")
if self.filters.get("customer_group"):
self.get_hierarchical_filters("Customer Group", "customer_group")
@@ -782,19 +798,19 @@ class ReceivablePayableReport(object):
if self.filters.get("payment_terms_template"):
self.qb_selection_filter.append(
- self.ple.party_isin(
- qb.from_(self.customer).where(
- self.customer.payment_terms == self.filters.get("payment_terms_template")
- )
+ self.ple.party.isin(
+ qb.from_(self.customer)
+ .select(self.customer.name)
+ .where(self.customer.payment_terms == self.filters.get("payment_terms_template"))
)
)
if self.filters.get("sales_partner"):
self.qb_selection_filter.append(
- self.ple.party_isin(
- qb.from_(self.customer).where(
- self.customer.default_sales_partner == self.filters.get("payment_terms_template")
- )
+ self.ple.party.isin(
+ qb.from_(self.customer)
+ .select(self.customer.name)
+ .where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
)
)
@@ -826,7 +842,7 @@ class ReceivablePayableReport(object):
customer = self.customer
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
- self.qb_selection_filter.append(ple.isin(ple.party.isin(customers)))
+ self.qb_selection_filter.append(ple.party.isin(customers))
def add_accounting_dimensions_filters(self):
accounting_dimensions = get_accounting_dimensions(as_list=False)
@@ -853,10 +869,15 @@ class ReceivablePayableReport(object):
def get_party_details(self, party):
if not party in self.party_details:
if self.party_type == "Customer":
+ fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
+
+ if self.filters.get("sales_partner"):
+ fields.append("default_sales_partner")
+
self.party_details[party] = frappe.db.get_value(
"Customer",
party,
- ["customer_name", "territory", "customer_group", "customer_primary_contact"],
+ fields,
as_dict=True,
)
else:
@@ -957,6 +978,9 @@ class ReceivablePayableReport(object):
if self.filters.show_sales_person:
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
+ if self.filters.sales_partner:
+ self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
+
if self.filters.party_type == "Supplier":
self.add_column(
label=_("Supplier Group"),
@@ -965,6 +989,9 @@ class ReceivablePayableReport(object):
options="Supplier Group",
)
+ if self.filters.show_remarks:
+ self.add_column(label=_("Remarks"), fieldname="remarks", fieldtype="Text", width=200),
+
def add_column(self, label, fieldname=None, fieldtype="Currency", options=None, width=120):
if not fieldname:
fieldname = scrub(label)
@@ -994,7 +1021,7 @@ class ReceivablePayableReport(object):
"{range3}-{range4}".format(
range3=cint(self.filters["range3"]) + 1, range4=self.filters["range4"]
),
- "{range4}-{above}".format(range4=cint(self.filters["range4"]) + 1, above=_("Above")),
+ _("{range4}-Above").format(range4=cint(self.filters["range4"]) + 1),
]
):
self.add_column(label=label, fieldname="range" + str(i + 1))
@@ -1013,3 +1040,17 @@ class ReceivablePayableReport(object):
"data": {"labels": self.ageing_column_labels, "datasets": rows},
"type": "percentage",
}
+
+ def get_exchange_rate_revaluations(self):
+ je = qb.DocType("Journal Entry")
+ results = (
+ qb.from_(je)
+ .select(je.name)
+ .where(
+ (je.company == self.filters.company)
+ & (je.posting_date.lte(self.filters.report_date))
+ & (je.voucher_type == "Exchange Rate Revaluation")
+ )
+ .run()
+ )
+ self.err_journals = [x[0] for x in results] if results else []
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index edddbbce21..afd02a006e 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -1,19 +1,55 @@
import unittest
import frappe
-from frappe.utils import add_days, getdate, today
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import add_days, flt, getdate, today
+from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
-class TestAccountsReceivable(unittest.TestCase):
- def test_accounts_receivable(self):
+class TestAccountsReceivable(FrappeTestCase):
+ def setUp(self):
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'")
+ frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")
+ frappe.db.sql("delete from `tabPayment Entry` where company='_Test Company 2'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'")
frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'")
+ frappe.db.sql("delete from `tabJournal Entry` where company='_Test Company 2'")
+ frappe.db.sql("delete from `tabExchange Rate Revaluation` where company='_Test Company 2'")
+ self.create_usd_account()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_usd_account(self):
+ name = "Debtors USD"
+ exists = frappe.db.get_list(
+ "Account", filters={"company": "_Test Company 2", "account_name": "Debtors USD"}
+ )
+ if exists:
+ self.debtors_usd = exists[0].name
+ else:
+ debtors = frappe.get_doc(
+ "Account",
+ frappe.db.get_list(
+ "Account", filters={"company": "_Test Company 2", "account_name": "Debtors"}
+ )[0].name,
+ )
+
+ debtors_usd = frappe.new_doc("Account")
+ debtors_usd.company = debtors.company
+ debtors_usd.account_name = "Debtors USD"
+ debtors_usd.account_currency = "USD"
+ debtors_usd.parent_account = debtors.parent_account
+ debtors_usd.account_type = debtors.account_type
+ self.debtors_usd = debtors_usd.save().name
+
+ def test_accounts_receivable(self):
filters = {
"company": "_Test Company 2",
"based_on_payment_terms": 1,
@@ -25,7 +61,7 @@ class TestAccountsReceivable(unittest.TestCase):
}
# check invoice grand total and invoiced column's value for 3 payment terms
- name = make_sales_invoice()
+ name = make_sales_invoice().name
report = execute(filters)
expected_data = [[100, 30], [100, 50], [100, 20]]
@@ -66,8 +102,116 @@ class TestAccountsReceivable(unittest.TestCase):
],
)
+ def test_payment_againt_po_in_receivable_report(self):
+ """
+ Payments made against Purchase Order will show up as outstanding amount
+ """
-def make_sales_invoice():
+ so = make_sales_order(
+ company="_Test Company 2",
+ customer="_Test Customer 2",
+ warehouse="Finished Goods - _TC2",
+ currency="EUR",
+ debit_to="Debtors - _TC2",
+ income_account="Sales - _TC2",
+ expense_account="Cost of Goods Sold - _TC2",
+ cost_center="Main - _TC2",
+ )
+
+ pe = get_payment_entry(so.doctype, so.name)
+ pe = pe.save().submit()
+
+ filters = {
+ "company": "_Test Company 2",
+ "based_on_payment_terms": 0,
+ "report_date": today(),
+ "range1": 30,
+ "range2": 60,
+ "range3": 90,
+ "range4": 120,
+ }
+
+ report = execute(filters)
+
+ expected_data_after_payment = [0, 1000, 0, -1000]
+
+ row = report[1][0]
+ self.assertEqual(
+ expected_data_after_payment,
+ [
+ row.invoiced,
+ row.paid,
+ row.credit_note,
+ row.outstanding,
+ ],
+ )
+
+ @change_settings(
+ "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
+ )
+ def test_exchange_revaluation_for_party(self):
+ """
+ Exchange Revaluation for party on Receivable/Payable shoule be included
+ """
+
+ company = "_Test Company 2"
+ customer = "_Test Customer 2"
+
+ # Using Exchange Gain/Loss account for unrealized as well.
+ company_doc = frappe.get_doc("Company", company)
+ company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
+ company_doc.save()
+
+ si = make_sales_invoice(no_payment_schedule=True, do_not_submit=True)
+ si.currency = "USD"
+ si.conversion_rate = 0.90
+ si.debit_to = self.debtors_usd
+ si = si.save().submit()
+
+ # Exchange Revaluation
+ err = frappe.new_doc("Exchange Rate Revaluation")
+ err.company = company
+ err.posting_date = today()
+ accounts = err.get_accounts_data()
+ err.extend("accounts", accounts)
+ err.accounts[0].new_exchange_rate = 0.95
+ row = err.accounts[0]
+ row.new_balance_in_base_currency = flt(
+ row.new_exchange_rate * flt(row.balance_in_account_currency)
+ )
+ row.gain_loss = row.new_balance_in_base_currency - flt(row.balance_in_base_currency)
+ err.set_total_gain_loss()
+ err = err.save().submit()
+
+ # Submit JV for ERR
+ err_journals = err.make_jv_entries()
+ je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
+ je = je.submit()
+
+ filters = {
+ "company": company,
+ "report_date": today(),
+ "range1": 30,
+ "range2": 60,
+ "range3": 90,
+ "range4": 120,
+ }
+ report = execute(filters)
+
+ expected_data_for_err = [0, -5, 0, 5]
+ row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0]
+ self.assertEqual(
+ expected_data_for_err,
+ [
+ row.invoiced,
+ row.paid,
+ row.credit_note,
+ row.outstanding,
+ ],
+ )
+
+
+def make_sales_invoice(no_payment_schedule=False, do_not_submit=False):
frappe.set_user("Administrator")
si = create_sales_invoice(
@@ -82,22 +226,26 @@ def make_sales_invoice():
do_not_save=1,
)
- si.append(
- "payment_schedule",
- dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30),
- )
- si.append(
- "payment_schedule",
- dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50),
- )
- si.append(
- "payment_schedule",
- dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20),
- )
+ if not no_payment_schedule:
+ si.append(
+ "payment_schedule",
+ dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30),
+ )
+ si.append(
+ "payment_schedule",
+ dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50),
+ )
+ si.append(
+ "payment_schedule",
+ dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20),
+ )
- si.submit()
+ si = si.save()
- return si.name
+ if not do_not_submit:
+ si = si.submit()
+
+ return si
def make_payment(docname):
diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
index 889f5a22a8..29217b04be 100644
--- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
+++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
@@ -121,6 +121,9 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if row.sales_person:
self.party_total[row.party].sales_person.append(row.sales_person)
+ if self.filters.sales_partner:
+ self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner")
+
def get_columns(self):
self.columns = []
self.add_column(
@@ -160,6 +163,10 @@ class AccountsReceivableSummary(ReceivablePayableReport):
)
if self.filters.show_sales_person:
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
+
+ if self.filters.sales_partner:
+ self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
+
else:
self.add_column(
label=_("Supplier Group"),
diff --git a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py
index ad9b1ba58e..5827697023 100644
--- a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py
+++ b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py
@@ -131,8 +131,36 @@ def get_assets(filters):
else
0
end), 0) as depreciation_amount_during_the_period
- from `tabAsset` a, `tabDepreciation Schedule` ds
- where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and a.name = ds.parent and ifnull(ds.journal_entry, '') != ''
+ from `tabAsset` a, `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds
+ where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and ads.asset = a.name and ads.docstatus=1 and ads.name = ds.parent and ifnull(ds.journal_entry, '') != ''
+ group by a.asset_category
+ union
+ SELECT a.asset_category,
+ ifnull(sum(case when gle.posting_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then
+ gle.debit
+ else
+ 0
+ end), 0) as accumulated_depreciation_as_on_from_date,
+ ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date >= %(from_date)s
+ and a.disposal_date <= %(to_date)s and gle.posting_date <= a.disposal_date then
+ gle.debit
+ else
+ 0
+ end), 0) as depreciation_eliminated_during_the_period,
+ ifnull(sum(case when gle.posting_date >= %(from_date)s and gle.posting_date <= %(to_date)s
+ and (ifnull(a.disposal_date, 0) = 0 or gle.posting_date <= a.disposal_date) then
+ gle.debit
+ else
+ 0
+ end), 0) as depreciation_amount_during_the_period
+ from `tabGL Entry` gle
+ join `tabAsset` a on
+ gle.against_voucher = a.name
+ join `tabAsset Category Account` aca on
+ aca.parent = a.asset_category and aca.company_name = %(company)s
+ join `tabCompany` company on
+ company.name = %(company)s
+ where a.docstatus=1 and a.company=%(company)s and a.calculate_depreciation=0 and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account)
group by a.asset_category
union
SELECT a.asset_category,
diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
index 9d2deea523..449ebdcd92 100644
--- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
+++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
@@ -22,8 +22,7 @@ def get_columns():
{
"label": _("Payment Document Type"),
"fieldname": "payment_document_type",
- "fieldtype": "Link",
- "options": "Doctype",
+ "fieldtype": "Data",
"width": 130,
},
{
@@ -33,15 +32,15 @@ def get_columns():
"options": "payment_document_type",
"width": 140,
},
- {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
+ {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120},
{"label": _("Cheque/Reference No"), "fieldname": "cheque_no", "width": 120},
- {"label": _("Clearance Date"), "fieldname": "clearance_date", "fieldtype": "Date", "width": 100},
+ {"label": _("Clearance Date"), "fieldname": "clearance_date", "fieldtype": "Date", "width": 120},
{
"label": _("Against Account"),
"fieldname": "against",
"fieldtype": "Link",
"options": "Account",
- "width": 170,
+ "width": 200,
},
{"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120},
]
diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
index fbc1a69ddc..b0a0e05f09 100644
--- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
+++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
@@ -21,7 +21,7 @@ def execute(filters=None):
if not filters.get("account"):
return columns, []
- account_currency = frappe.db.get_value("Account", filters.account, "account_currency")
+ account_currency = frappe.get_cached_value("Account", filters.account, "account_currency")
data = get_entries(filters)
diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js
index 718b6e2fcb..5955c2e0fc 100644
--- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js
+++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js
@@ -75,7 +75,7 @@ frappe.query_reports["Budget Variance Report"] = {
"formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
- if (column.fieldname.includes('variance')) {
+ if (column.fieldname.includes(__("variance"))) {
if (data[column.fieldname] < 0) {
value = "" + value + "";
diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
index 7b774ba740..96cfab9f11 100644
--- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
+++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
@@ -383,8 +383,8 @@ def get_chart_data(filters, columns, data):
"data": {
"labels": labels,
"datasets": [
- {"name": "Budget", "chartType": "bar", "values": budget_values},
- {"name": "Actual Expense", "chartType": "bar", "values": actual_values},
+ {"name": _("Budget"), "chartType": "bar", "values": budget_values},
+ {"name": _("Actual Expense"), "chartType": "bar", "values": actual_values},
],
},
"type": "bar",
diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py
index 75e983afc0..cb3c78a2b0 100644
--- a/erpnext/accounts/report/cash_flow/cash_flow.py
+++ b/erpnext/accounts/report/cash_flow/cash_flow.py
@@ -8,6 +8,7 @@ from frappe.utils import cint, cstr
from erpnext.accounts.report.financial_statements import (
get_columns,
+ get_cost_centers_with_children,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
@@ -160,10 +161,11 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
total = 0
for period in period_list:
start_date = get_start_date(period, accumulated_values, company)
+ filters.start_date = start_date
+ filters.end_date = period["to_date"]
+ filters.account_type = account_type
- amount = get_account_type_based_gl_data(
- company, start_date, period["to_date"], account_type, filters
- )
+ amount = get_account_type_based_gl_data(company, filters)
if amount and account_type == "Depreciation":
amount *= -1
@@ -175,12 +177,12 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
return data
-def get_account_type_based_gl_data(company, start_date, end_date, account_type, filters=None):
+def get_account_type_based_gl_data(company, filters=None):
cond = ""
filters = frappe._dict(filters or {})
if filters.include_default_book_entries:
- company_fb = frappe.db.get_value("Company", company, "default_finance_book")
+ company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
cond = """ AND (finance_book in (%s, %s, '') OR finance_book IS NULL)
""" % (
frappe.db.escape(filters.finance_book),
@@ -191,17 +193,21 @@ def get_account_type_based_gl_data(company, start_date, end_date, account_type,
frappe.db.escape(cstr(filters.finance_book))
)
+ if filters.get("cost_center"):
+ filters.cost_center = get_cost_centers_with_children(filters.cost_center)
+ cond += " and cost_center in %(cost_center)s"
+
gl_sum = frappe.db.sql_list(
"""
select sum(credit) - sum(debit)
from `tabGL Entry`
- where company=%s and posting_date >= %s and posting_date <= %s
+ where company=%(company)s and posting_date >= %(start_date)s and posting_date <= %(end_date)s
and voucher_type != 'Period Closing Voucher'
- and account in ( SELECT name FROM tabAccount WHERE account_type = %s) {cond}
+ and account in ( SELECT name FROM tabAccount WHERE account_type = %(account_type)s) {cond}
""".format(
cond=cond
),
- (company, start_date, end_date, account_type),
+ filters,
)
return gl_sum[0] if gl_sum and gl_sum[0] else 0
diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
index 98dbbf6c44..ddee9fc1e1 100644
--- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
+++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
@@ -268,10 +268,12 @@ def get_cash_flow_data(fiscal_year, companies, filters):
def get_account_type_based_data(account_type, companies, fiscal_year, filters):
data = {}
total = 0
+ filters.account_type = account_type
+ filters.start_date = fiscal_year.year_start_date
+ filters.end_date = fiscal_year.year_end_date
+
for company in companies:
- amount = get_account_type_based_gl_data(
- company, fiscal_year.year_start_date, fiscal_year.year_end_date, account_type, filters
- )
+ amount = get_account_type_based_gl_data(company, filters)
if amount and account_type == "Depreciation":
amount *= -1
@@ -533,9 +535,14 @@ def get_accounts(root_type, companies):
],
filters={"company": company, "root_type": root_type},
):
- if account.account_name not in added_accounts:
+ if account.account_number:
+ account_key = account.account_number + "-" + account.account_name
+ else:
+ account_key = account.account_name
+
+ if account_key not in added_accounts:
accounts.append(account)
- added_accounts.append(account.account_name)
+ added_accounts.append(account_key)
return accounts
@@ -637,7 +644,7 @@ def set_gl_entries_by_account(
"rgt": root_rgt,
"company": d.name,
"finance_book": filters.get("finance_book"),
- "company_fb": frappe.db.get_value("Company", d.name, "default_finance_book"),
+ "company_fb": frappe.get_cached_value("Company", d.name, "default_finance_book"),
},
as_dict=True,
)
diff --git a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py
index cafe95b360..4765e3b318 100644
--- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py
+++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py
@@ -26,6 +26,7 @@ class PartyLedgerSummaryReport(object):
)
self.get_gl_entries()
+ self.get_additional_columns()
self.get_return_invoices()
self.get_party_adjustment_amounts()
@@ -33,6 +34,42 @@ class PartyLedgerSummaryReport(object):
data = self.get_data()
return columns, data
+ def get_additional_columns(self):
+ """
+ Additional Columns for 'User Permission' based access control
+ """
+ from frappe import qb
+
+ if self.filters.party_type == "Customer":
+ self.territories = frappe._dict({})
+ self.customer_group = frappe._dict({})
+
+ customer = qb.DocType("Customer")
+ result = (
+ frappe.qb.from_(customer)
+ .select(
+ customer.name, customer.territory, customer.customer_group, customer.default_sales_partner
+ )
+ .where((customer.disabled == 0))
+ .run(as_dict=True)
+ )
+
+ for x in result:
+ self.territories[x.name] = x.territory
+ self.customer_group[x.name] = x.customer_group
+ else:
+ self.supplier_group = frappe._dict({})
+ supplier = qb.DocType("Supplier")
+ result = (
+ frappe.qb.from_(supplier)
+ .select(supplier.name, supplier.supplier_group)
+ .where((supplier.disabled == 0))
+ .run(as_dict=True)
+ )
+
+ for x in result:
+ self.supplier_group[x.name] = x.supplier_group
+
def get_columns(self):
columns = [
{
@@ -116,6 +153,35 @@ class PartyLedgerSummaryReport(object):
},
]
+ # Hidden columns for handling 'User Permissions'
+ if self.filters.party_type == "Customer":
+ columns += [
+ {
+ "label": _("Territory"),
+ "fieldname": "territory",
+ "fieldtype": "Link",
+ "options": "Territory",
+ "hidden": 1,
+ },
+ {
+ "label": _("Customer Group"),
+ "fieldname": "customer_group",
+ "fieldtype": "Link",
+ "options": "Customer Group",
+ "hidden": 1,
+ },
+ ]
+ else:
+ columns += [
+ {
+ "label": _("Supplier Group"),
+ "fieldname": "supplier_group",
+ "fieldtype": "Link",
+ "options": "Supplier Group",
+ "hidden": 1,
+ }
+ ]
+
return columns
def get_data(self):
@@ -143,6 +209,12 @@ class PartyLedgerSummaryReport(object):
),
)
+ if self.filters.party_type == "Customer":
+ self.party_data[gle.party].update({"territory": self.territories.get(gle.party)})
+ self.party_data[gle.party].update({"customer_group": self.customer_group.get(gle.party)})
+ else:
+ self.party_data[gle.party].update({"supplier_group": self.supplier_group.get(gle.party)})
+
amount = gle.get(invoice_dr_or_cr) - gle.get(reverse_dr_or_cr)
self.party_data[gle.party].closing_balance += amount
@@ -220,8 +292,8 @@ class PartyLedgerSummaryReport(object):
if self.filters.party_type == "Customer":
if self.filters.get("customer_group"):
- lft, rgt = frappe.db.get_value(
- "Customer Group", self.filters.get("customer_group"), ["lft", "rgt"]
+ lft, rgt = frappe.get_cached_value(
+ "Customer Group", self.filters["customer_group"], ["lft", "rgt"]
)
conditions.append(
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
index 1eb257ac85..3e11643776 100644
--- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
@@ -378,15 +378,14 @@ class Deferred_Revenue_and_Expense_Report(object):
ret += [{}]
# add total row
- if ret is not []:
- if self.filters.type == "Revenue":
- total_row = frappe._dict({"name": "Total Deferred Income"})
- elif self.filters.type == "Expense":
- total_row = frappe._dict({"name": "Total Deferred Expense"})
+ if self.filters.type == "Revenue":
+ total_row = frappe._dict({"name": "Total Deferred Income"})
+ elif self.filters.type == "Expense":
+ total_row = frappe._dict({"name": "Total Deferred Expense"})
- for idx, period in enumerate(self.period_list, 0):
- total_row[period.key] = self.period_total[idx].total
- ret.append(total_row)
+ for idx, period in enumerate(self.period_list, 0):
+ total_row[period.key] = self.period_total[idx].total
+ ret.append(total_row)
return ret
@@ -396,7 +395,7 @@ class Deferred_Revenue_and_Expense_Report(object):
"labels": [period.label for period in self.period_list],
"datasets": [
{
- "name": "Actual Posting",
+ "name": _("Actual Posting"),
"chartType": "bar",
"values": [x.actual for x in self.period_total],
}
@@ -410,7 +409,7 @@ class Deferred_Revenue_and_Expense_Report(object):
if self.filters.with_upcoming_postings:
chart["data"]["datasets"].append(
- {"name": "Expected", "chartType": "line", "values": [x.total for x in self.period_total]}
+ {"name": _("Expected"), "chartType": "line", "values": [x.total for x in self.period_total]}
)
return chart
diff --git a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
index ecad9f104f..5939a26deb 100644
--- a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
+++ b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
@@ -90,7 +90,7 @@ def set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_ac
gl_filters["dimensions"] = set(dimension_list)
if filters.get("include_default_book_entries"):
- gl_filters["company_fb"] = frappe.db.get_value(
+ gl_filters["company_fb"] = frappe.get_cached_value(
"Company", filters.company, "default_finance_book"
)
diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py
index 36825771f8..8c6fe43a93 100644
--- a/erpnext/accounts/report/financial_statements.py
+++ b/erpnext/accounts/report/financial_statements.py
@@ -440,7 +440,7 @@ def set_gl_entries_by_account(
}
if filters.get("include_default_book_entries"):
- gl_filters["company_fb"] = frappe.db.get_value("Company", company, "default_finance_book")
+ gl_filters["company_fb"] = frappe.get_cached_value("Company", company, "default_finance_book")
for key, value in filters.items():
if value:
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.html b/erpnext/accounts/report/general_ledger/general_ledger.html
index 378fa3791c..475be92add 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.html
+++ b/erpnext/accounts/report/general_ledger/general_ledger.html
@@ -25,8 +25,8 @@
{%= __("Date") %} |
- {%= __("Ref") %} |
- {%= __("Party") %} |
+ {%= __("Reference") %} |
+ {%= __("Remarks") %} |
{%= __("Debit") %} |
{%= __("Credit") %} |
{%= __("Balance (Dr - Cr)") %} |
@@ -45,29 +45,28 @@
{% } %}
- {{ __("Against") }}: {%= data[i].against %}
{%= __("Remarks") %}: {%= data[i].remarks %}
{% if(data[i].bill_no) { %}
{%= __("Supplier Invoice No") %}: {%= data[i].bill_no %}
{% } %}
- {%= format_currency(data[i].debit, filters.presentation_currency) %} |
+ {%= format_currency(data[i].debit, filters.presentation_currency || data[i].account_currency) %}
- {%= format_currency(data[i].credit, filters.presentation_currency) %} |
+ {%= format_currency(data[i].credit, filters.presentation_currency || data[i].account_currency) %}
{% } else { %}
|
|
{%= frappe.format(data[i].account, {fieldtype: "Link"}) || " " %} |
- {%= data[i].account && format_currency(data[i].debit, filters.presentation_currency) %}
+ {%= data[i].account && format_currency(data[i].debit, filters.presentation_currency || data[i].account_currency) %}
|
- {%= data[i].account && format_currency(data[i].credit, filters.presentation_currency) %}
+ {%= data[i].account && format_currency(data[i].credit, filters.presentation_currency || data[i].account_currency) %}
|
{% } %}
- {%= format_currency(data[i].balance, filters.presentation_currency) %}
+ {%= format_currency(data[i].balance, filters.presentation_currency || data[i].account_currency) %}
|
{% } %}
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index e77e828e16..27b84c4e77 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -121,7 +121,7 @@ def set_account_currency(filters):
if is_same_account_currency:
account_currency = currency
- elif filters.get("party"):
+ elif filters.get("party") and filters.get("party_type"):
gle_currency = frappe.db.get_value(
"GL Entry",
{"party_type": filters.party_type, "party": filters.party[0], "company": filters.company},
@@ -134,7 +134,7 @@ def set_account_currency(filters):
account_currency = (
None
if filters.party_type in ["Employee", "Shareholder", "Member"]
- else frappe.db.get_value(filters.party_type, filters.party[0], "default_currency")
+ else frappe.get_cached_value(filters.party_type, filters.party[0], "default_currency")
)
filters["account_currency"] = account_currency or filters.company_currency
@@ -174,7 +174,7 @@ def get_gl_entries(filters, accounting_dimensions):
order_by_statement = "order by account, posting_date, creation"
if filters.get("include_default_book_entries"):
- filters["company_fb"] = frappe.db.get_value(
+ filters["company_fb"] = frappe.get_cached_value(
"Company", filters.get("company"), "default_finance_book"
)
@@ -237,7 +237,7 @@ def get_conditions(filters):
or filters.get("party")
or filters.get("group_by") in ["Group by Account", "Group by Party"]
):
- conditions.append("posting_date >=%(from_date)s")
+ conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')")
conditions.append("(posting_date <=%(to_date)s or is_opening = 'Yes')")
@@ -286,9 +286,11 @@ def get_accounts_with_children(accounts):
all_accounts = []
for d in accounts:
- if frappe.db.exists("Account", d):
- lft, rgt = frappe.db.get_value("Account", d, ["lft", "rgt"])
- children = frappe.get_all("Account", filters={"lft": [">=", lft], "rgt": ["<=", rgt]})
+ account = frappe.get_cached_doc("Account", d)
+ if account:
+ children = frappe.get_all(
+ "Account", filters={"lft": [">=", account.lft], "rgt": ["<=", account.rgt]}
+ )
all_accounts += [c.name for c in children]
else:
frappe.throw(_("Account: {0} does not exist").format(d))
@@ -524,7 +526,7 @@ def get_columns(filters):
"options": "GL Entry",
"hidden": 1,
},
- {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 90},
+ {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
{
"label": _("Account"),
"fieldname": "account",
@@ -536,13 +538,13 @@ def get_columns(filters):
"label": _("Debit ({0})").format(currency),
"fieldname": "debit",
"fieldtype": "Float",
- "width": 100,
+ "width": 130,
},
{
"label": _("Credit ({0})").format(currency),
"fieldname": "credit",
"fieldtype": "Float",
- "width": 100,
+ "width": 130,
},
{
"label": _("Balance ({0})").format(currency),
diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py
index b10e769618..c563785763 100644
--- a/erpnext/accounts/report/general_ledger/test_general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py
@@ -109,8 +109,7 @@ class TestGeneralLedger(FrappeTestCase):
frappe.db.set_value(
"Company", company, "unrealized_exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC"
)
- revaluation_jv = revaluation.make_jv_entry()
- revaluation_jv = frappe.get_doc(revaluation_jv)
+ revaluation_jv = revaluation.make_jv_for_revaluation()
revaluation_jv.cost_center = "_Test Cost Center - _TC"
for acc in revaluation_jv.get("accounts"):
acc.cost_center = "_Test Cost Center - _TC"
diff --git a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
index 9d56678541..cd5f366707 100644
--- a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
+++ b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
@@ -155,7 +155,6 @@ def adjust_account(data, period_list, consolidated=False):
for d in data:
for period in period_list:
key = period if consolidated else period.key
- d[key] = totals[d["account"]]
d["total"] = totals[d["account"]]
return data
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 3d37b5898c..e89d42977b 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -4,7 +4,7 @@
frappe.query_reports["Gross Profit"] = {
"filters": [
{
- "fieldname":"company",
+ "fieldname": "company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
@@ -12,46 +12,72 @@ frappe.query_reports["Gross Profit"] = {
"reqd": 1
},
{
- "fieldname":"from_date",
+ "fieldname": "from_date",
"label": __("From Date"),
"fieldtype": "Date",
"default": frappe.defaults.get_user_default("year_start_date"),
"reqd": 1
},
{
- "fieldname":"to_date",
+ "fieldname": "to_date",
"label": __("To Date"),
"fieldtype": "Date",
"default": frappe.defaults.get_user_default("year_end_date"),
"reqd": 1
},
{
- "fieldname":"sales_invoice",
+ "fieldname": "sales_invoice",
"label": __("Sales Invoice"),
"fieldtype": "Link",
"options": "Sales Invoice"
},
{
- "fieldname":"group_by",
+ "fieldname": "group_by",
"label": __("Group By"),
"fieldtype": "Select",
"options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term",
"default": "Invoice"
},
+ {
+ "fieldname": "item_group",
+ "label": __("Item Group"),
+ "fieldtype": "Link",
+ "options": "Item Group"
+ },
+ {
+ "fieldname": "sales_person",
+ "label": __("Sales Person"),
+ "fieldtype": "Link",
+ "options": "Sales Person"
+ },
+ {
+ "fieldname": "warehouse",
+ "label": __("Warehouse"),
+ "fieldtype": "Link",
+ "options": "Warehouse",
+ "get_query": function () {
+ var company = frappe.query_report.get_filter_value('company');
+ return {
+ filters: [
+ ["Warehouse", "company", "=", company]
+ ]
+ };
+ },
+ },
],
"tree": true,
"name_field": "parent",
"parent_field": "parent_invoice",
"initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) {
- if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
+ if (column.fieldname == "sales_invoice" && column.options == "Item" && data && data.indent == 0) {
column._options = "Sales Invoice";
} else {
column._options = "Item";
}
value = default_formatter(value, row, column, data);
- if (data && (data.indent == 0.0 || row[1].content == "Total")) {
+ if (data && (data.indent == 0.0 || (row[1] && row[1].content == "Total"))) {
value = $(`${value}`);
var $value = $(value).css("font-weight", "bold");
value = $value.wrap("").parent().html();
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index 526ea9d6e2..fde4de8402 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -3,10 +3,12 @@
import frappe
-from frappe import _, scrub
+from frappe import _, qb, scrub
+from frappe.query_builder import Order
from frappe.utils import cint, flt, formatdate
from erpnext.controllers.queries import get_match_cond
+from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import get_incoming_rate
@@ -393,15 +395,16 @@ def get_column_names():
class GrossProfitGenerator(object):
def __init__(self, filters=None):
+ self.sle = {}
self.data = []
self.average_buying_rate = {}
self.filters = frappe._dict(filters)
self.load_invoice_items()
+ self.get_delivery_notes()
if filters.group_by == "Invoice":
self.group_items_by_invoice()
- self.load_stock_ledger_entries()
self.load_product_bundle()
self.load_non_stock_items()
self.get_returned_invoice_items()
@@ -436,6 +439,18 @@ class GrossProfitGenerator(object):
row.delivery_note, frappe._dict()
)
row.item_row = row.dn_detail
+ # Update warehouse and base_amount from 'Packed Item' List
+ if product_bundles and not row.parent:
+ # For Packed Items, row.parent_invoice will be the Bundle name
+ product_bundle = product_bundles.get(row.parent_invoice)
+ if product_bundle:
+ for packed_item in product_bundle:
+ if (
+ packed_item.get("item_code") == row.item_code
+ and packed_item.get("parent_detail_docname") == row.item_row
+ ):
+ row.warehouse = packed_item.warehouse
+ row.base_amount = packed_item.base_amount
# get buying amount
if row.item_code in product_bundles:
@@ -500,7 +515,7 @@ class GrossProfitGenerator(object):
invoice_portion = 100
elif row.invoice_portion:
invoice_portion = row.invoice_portion
- else:
+ elif row.payment_amount:
invoice_portion = row.payment_amount * 100 / row.base_net_amount
if i == 0:
@@ -586,10 +601,28 @@ class GrossProfitGenerator(object):
buying_amount = 0.0
for packed_item in product_bundle:
if packed_item.get("parent_detail_docname") == row.item_row:
- buying_amount += self.get_buying_amount(row, packed_item.item_code)
+ packed_item_row = row.copy()
+ packed_item_row.warehouse = packed_item.warehouse
+ buying_amount += self.get_buying_amount(packed_item_row, packed_item.item_code)
return flt(buying_amount, self.currency_precision)
+ def calculate_buying_amount_from_sle(self, row, my_sle, parenttype, parent, item_row, item_code):
+ for i, sle in enumerate(my_sle):
+ # find the stock valution rate from stock ledger entry
+ if (
+ sle.voucher_type == parenttype
+ and parent == sle.voucher_no
+ and sle.voucher_detail_no == item_row
+ ):
+ previous_stock_value = len(my_sle) > i + 1 and flt(my_sle[i + 1].stock_value) or 0.0
+
+ if previous_stock_value:
+ return abs(previous_stock_value - flt(sle.stock_value)) * flt(row.qty) / abs(flt(sle.qty))
+ else:
+ return flt(row.qty) * self.get_average_buying_rate(row, item_code)
+ return 0.0
+
def get_buying_amount(self, row, item_code):
# IMP NOTE
# stock_ledger_entries should already be filtered by item_code and warehouse and
@@ -600,29 +633,54 @@ class GrossProfitGenerator(object):
return flt(row.qty) * item_rate
else:
- my_sle = self.sle.get((item_code, row.warehouse))
+ my_sle = self.get_stock_ledger_entries(item_code, row.warehouse)
if (row.update_stock or row.dn_detail) and my_sle:
parenttype, parent = row.parenttype, row.parent
if row.dn_detail:
parenttype, parent = "Delivery Note", row.delivery_note
- for i, sle in enumerate(my_sle):
- # find the stock valution rate from stock ledger entry
- if (
- sle.voucher_type == parenttype
- and parent == sle.voucher_no
- and sle.voucher_detail_no == row.item_row
- ):
- previous_stock_value = len(my_sle) > i + 1 and flt(my_sle[i + 1].stock_value) or 0.0
-
- if previous_stock_value:
- return (previous_stock_value - flt(sle.stock_value)) * flt(row.qty) / abs(flt(sle.qty))
- else:
- return flt(row.qty) * self.get_average_buying_rate(row, item_code)
+ return self.calculate_buying_amount_from_sle(
+ row, my_sle, parenttype, parent, row.item_row, item_code
+ )
+ elif self.delivery_notes.get((row.parent, row.item_code), None):
+ # check if Invoice has delivery notes
+ dn = self.delivery_notes.get((row.parent, row.item_code))
+ parenttype, parent, item_row, warehouse = (
+ "Delivery Note",
+ dn["delivery_note"],
+ dn["item_row"],
+ dn["warehouse"],
+ )
+ my_sle = self.get_stock_ledger_entries(item_code, row.warehouse)
+ return self.calculate_buying_amount_from_sle(
+ row, my_sle, parenttype, parent, item_row, item_code
+ )
+ elif row.sales_order and row.so_detail:
+ incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code)
+ if incoming_amount:
+ return incoming_amount
else:
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
- return 0.0
+ return flt(row.qty) * self.get_average_buying_rate(row, item_code)
+
+ def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
+ from frappe.query_builder.functions import Sum
+
+ delivery_note_item = frappe.qb.DocType("Delivery Note Item")
+
+ query = (
+ frappe.qb.from_(delivery_note_item)
+ .select(Sum(delivery_note_item.incoming_rate * delivery_note_item.stock_qty))
+ .where(delivery_note_item.docstatus == 1)
+ .where(delivery_note_item.item_code == item_code)
+ .where(delivery_note_item.against_sales_order == sales_order)
+ .where(delivery_note_item.so_detail == so_detail)
+ .groupby(delivery_note_item.item_code)
+ )
+
+ incoming_amount = query.run()
+ return flt(incoming_amount[0][0]) if incoming_amount else 0
def get_average_buying_rate(self, row, item_code):
args = row
@@ -676,6 +734,17 @@ class GrossProfitGenerator(object):
if self.filters.to_date:
conditions += " and posting_date <= %(to_date)s"
+ if self.filters.item_group:
+ conditions += " and {0}".format(get_item_group_condition(self.filters.item_group))
+
+ if self.filters.sales_person:
+ conditions += """
+ and exists(select 1
+ from `tabSales Team` st
+ where st.parent = `tabSales Invoice`.name
+ and st.sales_person = %(sales_person)s)
+ """
+
if self.filters.group_by == "Sales Person":
sales_person_cols = ", sales.sales_person, sales.allocated_amount, sales.incentives"
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
@@ -703,6 +772,13 @@ class GrossProfitGenerator(object):
if self.filters.get("item_code"):
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
+ if self.filters.get("warehouse"):
+ warehouse_details = frappe.db.get_value(
+ "Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
+ )
+ if warehouse_details:
+ conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
+
self.si_list = frappe.db.sql(
"""
select
@@ -713,7 +789,8 @@ class GrossProfitGenerator(object):
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
`tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group,
- `tabSales Invoice Item`.brand, `tabSales Invoice Item`.dn_detail,
+ `tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail,
+ `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail,
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
@@ -723,6 +800,7 @@ class GrossProfitGenerator(object):
from
`tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
+ join `tabItem` item on item.name = `tabSales Invoice Item`.item_code
{sales_team_table}
{payment_term_table}
where
@@ -740,6 +818,29 @@ class GrossProfitGenerator(object):
as_dict=1,
)
+ def get_delivery_notes(self):
+ self.delivery_notes = frappe._dict({})
+ if self.si_list:
+ invoices = [x.parent for x in self.si_list]
+ dni = qb.DocType("Delivery Note Item")
+ delivery_notes = (
+ qb.from_(dni)
+ .select(
+ dni.against_sales_invoice.as_("sales_invoice"),
+ dni.item_code,
+ dni.warehouse,
+ dni.parent.as_("delivery_note"),
+ dni.name.as_("item_row"),
+ )
+ .where((dni.docstatus == 1) & (dni.against_sales_invoice.isin(invoices)))
+ .groupby(dni.against_sales_invoice, dni.item_code)
+ .orderby(dni.creation, order=Order.desc)
+ .run(as_dict=True)
+ )
+
+ for entry in delivery_notes:
+ self.delivery_notes[(entry.sales_invoice, entry.item_code)] = entry
+
def group_items_by_invoice(self):
"""
Turns list of Sales Invoice Items to a tree of Sales Invoices with their Items as children.
@@ -843,34 +944,59 @@ class GrossProfitGenerator(object):
"Item", item_code, ["item_name", "description", "item_group", "brand"]
)
- def load_stock_ledger_entries(self):
- res = frappe.db.sql(
- """select item_code, voucher_type, voucher_no,
- voucher_detail_no, stock_value, warehouse, actual_qty as qty
- from `tabStock Ledger Entry`
- where company=%(company)s and is_cancelled = 0
- order by
- item_code desc, warehouse desc, posting_date desc,
- posting_time desc, creation desc""",
- self.filters,
- as_dict=True,
- )
- self.sle = {}
- for r in res:
- if (r.item_code, r.warehouse) not in self.sle:
- self.sle[(r.item_code, r.warehouse)] = []
+ def get_stock_ledger_entries(self, item_code, warehouse):
+ if item_code and warehouse:
+ if (item_code, warehouse) not in self.sle:
+ sle = qb.DocType("Stock Ledger Entry")
+ res = (
+ qb.from_(sle)
+ .select(
+ sle.item_code,
+ sle.voucher_type,
+ sle.voucher_no,
+ sle.voucher_detail_no,
+ sle.stock_value,
+ sle.warehouse,
+ sle.actual_qty.as_("qty"),
+ )
+ .where(
+ (sle.company == self.filters.company)
+ & (sle.item_code == item_code)
+ & (sle.warehouse == warehouse)
+ & (sle.is_cancelled == 0)
+ )
+ .orderby(sle.item_code)
+ .orderby(sle.warehouse, sle.posting_date, sle.posting_time, sle.creation, order=Order.desc)
+ .run(as_dict=True)
+ )
- self.sle[(r.item_code, r.warehouse)].append(r)
+ self.sle[(item_code, warehouse)] = res
+
+ return self.sle[(item_code, warehouse)]
+ return []
def load_product_bundle(self):
self.product_bundles = {}
- for d in frappe.db.sql(
- """select parenttype, parent, parent_item,
- item_code, warehouse, -1*qty as total_qty, parent_detail_docname
- from `tabPacked Item` where docstatus=1""",
- as_dict=True,
- ):
+ pki = qb.DocType("Packed Item")
+
+ pki_query = (
+ frappe.qb.from_(pki)
+ .select(
+ pki.parenttype,
+ pki.parent,
+ pki.parent_item,
+ pki.item_code,
+ pki.warehouse,
+ (-1 * pki.qty).as_("total_qty"),
+ pki.rate,
+ (pki.rate * pki.qty).as_("base_amount"),
+ pki.parent_detail_docname,
+ )
+ .where(pki.docstatus == 1)
+ )
+
+ for d in pki_query.run(as_dict=True):
self.product_bundles.setdefault(d.parenttype, frappe._dict()).setdefault(
d.parent, frappe._dict()
).setdefault(d.parent_item, []).append(d)
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
new file mode 100644
index 0000000000..21681bef5b
--- /dev/null
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -0,0 +1,383 @@
+import frappe
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, flt, nowdate
+
+from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.report.gross_profit.gross_profit import execute
+from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+
+class TestGrossProfit(FrappeTestCase):
+ def setUp(self):
+ self.create_company()
+ self.create_item()
+ self.create_bundle()
+ self.create_customer()
+ self.create_sales_invoice()
+ self.clear_old_entries()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_company(self):
+ company_name = "_Test Gross Profit"
+ abbr = "_GP"
+ if frappe.db.exists("Company", company_name):
+ company = frappe.get_doc("Company", company_name)
+ else:
+ company = frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": company_name,
+ "country": "India",
+ "default_currency": "INR",
+ "create_chart_of_accounts_based_on": "Standard Template",
+ "chart_of_accounts": "Standard",
+ }
+ )
+ company = company.save()
+
+ self.company = company.name
+ self.cost_center = company.cost_center
+ self.warehouse = "Stores - " + abbr
+ self.finished_warehouse = "Finished Goods - " + abbr
+ self.income_account = "Sales - " + abbr
+ self.expense_account = "Cost of Goods Sold - " + abbr
+ self.debit_to = "Debtors - " + abbr
+ self.creditors = "Creditors - " + abbr
+
+ def create_item(self):
+ item = create_item(
+ item_code="_Test GP Item", is_stock_item=1, company=self.company, warehouse=self.warehouse
+ )
+ self.item = item if isinstance(item, str) else item.item_code
+
+ def create_bundle(self):
+ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+
+ item2 = create_item(
+ item_code="_Test GP Item 2", is_stock_item=1, company=self.company, warehouse=self.warehouse
+ )
+ self.item2 = item2 if isinstance(item2, str) else item2.item_code
+
+ # This will be parent item
+ bundle = create_item(
+ item_code="_Test GP bundle", is_stock_item=0, company=self.company, warehouse=self.warehouse
+ )
+ self.bundle = bundle if isinstance(bundle, str) else bundle.item_code
+
+ # Create Product Bundle
+ self.product_bundle = make_product_bundle(parent=self.bundle, items=[self.item, self.item2])
+
+ def create_customer(self):
+ name = "_Test GP Customer"
+ if frappe.db.exists("Customer", name):
+ self.customer = name
+ else:
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = name
+ customer.type = "Individual"
+ customer.save()
+ self.customer = customer.name
+
+ def create_sales_invoice(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ sinv = create_sales_invoice(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.customer,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return sinv
+
+ def create_delivery_note(
+ self, item=None, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in Delivery Note
+ """
+ dnote = create_delivery_note(
+ company=self.company,
+ customer=self.customer,
+ currency="INR",
+ item=item or self.item,
+ qty=qty,
+ rate=rate,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ return_against=None,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return dnote
+
+ def clear_old_entries(self):
+ doctype_list = [
+ "Sales Invoice",
+ "GL Entry",
+ "Payment Ledger Entry",
+ "Stock Entry",
+ "Stock Ledger Entry",
+ "Delivery Note",
+ ]
+ for doctype in doctype_list:
+ qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
+
+ def test_invoice_without_only_delivery_note(self):
+ """
+ Test buying amount for Invoice without `update_stock` flag set but has Delivery Note
+ """
+ se = make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=1,
+ basic_rate=100,
+ do_not_submit=True,
+ )
+ item = se.items[0]
+ se.append(
+ "items",
+ {
+ "item_code": item.item_code,
+ "s_warehouse": item.s_warehouse,
+ "t_warehouse": item.t_warehouse,
+ "qty": 1,
+ "basic_rate": 200,
+ "conversion_factor": item.conversion_factor or 1.0,
+ "transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
+ "serial_no": item.serial_no,
+ "batch_no": item.batch_no,
+ "cost_center": item.cost_center,
+ "expense_account": item.expense_account,
+ },
+ )
+ se = se.save().submit()
+
+ sinv = create_sales_invoice(
+ qty=1,
+ rate=100,
+ company=self.company,
+ customer=self.customer,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ )
+
+ filters = frappe._dict(
+ company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
+ )
+
+ columns, data = execute(filters=filters)
+
+ # Without Delivery Note, buying rate should be 150
+ expected_entry_without_dn = {
+ "parent_invoice": sinv.name,
+ "currency": "INR",
+ "sales_invoice": self.item,
+ "customer": self.customer,
+ "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
+ "item_code": self.item,
+ "item_name": self.item,
+ "warehouse": "Stores - _GP",
+ "qty": 1.0,
+ "avg._selling_rate": 100.0,
+ "valuation_rate": 150.0,
+ "selling_amount": 100.0,
+ "buying_amount": 150.0,
+ "gross_profit": -50.0,
+ "gross_profit_%": -50.0,
+ }
+ gp_entry = [x for x in data if x.parent_invoice == sinv.name]
+ self.assertDictContainsSubset(expected_entry_without_dn, gp_entry[0])
+
+ # make delivery note
+ dn = make_delivery_note(sinv.name)
+ dn.items[0].qty = 1
+ dn = dn.save().submit()
+
+ columns, data = execute(filters=filters)
+
+ # Without Delivery Note, buying rate should be 100
+ expected_entry_with_dn = {
+ "parent_invoice": sinv.name,
+ "currency": "INR",
+ "sales_invoice": self.item,
+ "customer": self.customer,
+ "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
+ "item_code": self.item,
+ "item_name": self.item,
+ "warehouse": "Stores - _GP",
+ "qty": 1.0,
+ "avg._selling_rate": 100.0,
+ "valuation_rate": 100.0,
+ "selling_amount": 100.0,
+ "buying_amount": 100.0,
+ "gross_profit": 0.0,
+ "gross_profit_%": 0.0,
+ }
+ gp_entry = [x for x in data if x.parent_invoice == sinv.name]
+ self.assertDictContainsSubset(expected_entry_with_dn, gp_entry[0])
+
+ def test_bundled_delivery_note_with_different_warehouses(self):
+ """
+ Test Delivery Note with bundled item. Packed Item from the bundle having different warehouses
+ """
+ se = make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=1,
+ basic_rate=100,
+ do_not_submit=True,
+ )
+ item = se.items[0]
+ se.append(
+ "items",
+ {
+ "item_code": self.item2,
+ "s_warehouse": "",
+ "t_warehouse": self.finished_warehouse,
+ "qty": 1,
+ "basic_rate": 100,
+ "conversion_factor": item.conversion_factor or 1.0,
+ "transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
+ "serial_no": item.serial_no,
+ "batch_no": item.batch_no,
+ "cost_center": item.cost_center,
+ "expense_account": item.expense_account,
+ },
+ )
+ se = se.save().submit()
+
+ # Make a Delivery note with Product bundle
+ # Packed Items will have different warehouses
+ dnote = self.create_delivery_note(item=self.bundle, qty=1, rate=200, do_not_submit=True)
+ dnote.packed_items[1].warehouse = self.finished_warehouse
+ dnote = dnote.submit()
+
+ # make Sales Invoice for above delivery note
+ sinv = make_sales_invoice(dnote.name)
+ sinv = sinv.save().submit()
+
+ filters = frappe._dict(
+ company=self.company,
+ from_date=nowdate(),
+ to_date=nowdate(),
+ group_by="Invoice",
+ sales_invoice=sinv.name,
+ )
+
+ columns, data = execute(filters=filters)
+ self.assertGreater(len(data), 0)
+
+ def test_order_connected_dn_and_inv(self):
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+
+ """
+ Test gp calculation when invoice and delivery note aren't directly connected.
+ SO -- INV
+ |
+ DN
+ """
+ se = make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=3,
+ basic_rate=100,
+ do_not_submit=True,
+ )
+ item = se.items[0]
+ se.append(
+ "items",
+ {
+ "item_code": item.item_code,
+ "s_warehouse": item.s_warehouse,
+ "t_warehouse": item.t_warehouse,
+ "qty": 10,
+ "basic_rate": 200,
+ "conversion_factor": item.conversion_factor or 1.0,
+ "transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
+ "serial_no": item.serial_no,
+ "batch_no": item.batch_no,
+ "cost_center": item.cost_center,
+ "expense_account": item.expense_account,
+ },
+ )
+ se = se.save().submit()
+
+ so = make_sales_order(
+ customer=self.customer,
+ company=self.company,
+ warehouse=self.warehouse,
+ item=self.item,
+ qty=4,
+ do_not_save=False,
+ do_not_submit=False,
+ )
+
+ from erpnext.selling.doctype.sales_order.sales_order import (
+ make_delivery_note,
+ make_sales_invoice,
+ )
+
+ make_delivery_note(so.name).submit()
+ sinv = make_sales_invoice(so.name).submit()
+
+ filters = frappe._dict(
+ company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
+ )
+
+ columns, data = execute(filters=filters)
+ expected_entry = {
+ "parent_invoice": sinv.name,
+ "currency": "INR",
+ "sales_invoice": self.item,
+ "customer": self.customer,
+ "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
+ "item_code": self.item,
+ "item_name": self.item,
+ "warehouse": "Stores - _GP",
+ "qty": 4.0,
+ "avg._selling_rate": 100.0,
+ "valuation_rate": 125.0,
+ "selling_amount": 400.0,
+ "buying_amount": 500.0,
+ "gross_profit": -100.0,
+ "gross_profit_%": -25.0,
+ }
+ gp_entry = [x for x in data if x.parent_invoice == sinv.name]
+ self.assertDictContainsSubset(expected_entry, gp_entry[0])
diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
index c04b9c7125..d34c21348c 100644
--- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
+++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
@@ -53,9 +53,6 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum
item_details = get_item_details()
for d in item_list:
- if not d.stock_qty:
- continue
-
item_record = item_details.get(d.item_code)
purchase_receipt = None
@@ -94,7 +91,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum
"expense_account": expense_account,
"stock_qty": d.stock_qty,
"stock_uom": d.stock_uom,
- "rate": d.base_net_amount / d.stock_qty,
+ "rate": d.base_net_amount / d.stock_qty if d.stock_qty else d.base_net_amount,
"amount": d.base_net_amount,
}
)
diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
index ac70666654..c987231fe1 100644
--- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
+++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
@@ -97,6 +97,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum
row.update({"rate": d.base_net_rate, "amount": d.base_net_amount})
total_tax = 0
+ total_other_charges = 0
for tax in tax_columns:
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
row.update(
@@ -105,10 +106,18 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum
frappe.scrub(tax + " Amount"): item_tax.get("tax_amount", 0),
}
)
- total_tax += flt(item_tax.get("tax_amount"))
+ if item_tax.get("is_other_charges"):
+ total_other_charges += flt(item_tax.get("tax_amount"))
+ else:
+ total_tax += flt(item_tax.get("tax_amount"))
row.update(
- {"total_tax": total_tax, "total": d.base_net_amount + total_tax, "currency": company_currency}
+ {
+ "total_tax": total_tax,
+ "total_other_charges": total_other_charges,
+ "total": d.base_net_amount + total_tax,
+ "currency": company_currency,
+ }
)
if filters.get("group_by"):
@@ -477,7 +486,7 @@ def get_tax_accounts(
tax_details = frappe.db.sql(
"""
select
- name, parent, description, item_wise_tax_detail,
+ name, parent, description, item_wise_tax_detail, account_head,
charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount
from `tab%s`
where
@@ -493,11 +502,22 @@ def get_tax_accounts(
tuple([doctype] + list(invoice_item_row)),
)
+ account_doctype = frappe.qb.DocType("Account")
+
+ query = (
+ frappe.qb.from_(account_doctype)
+ .select(account_doctype.name)
+ .where((account_doctype.account_type == "Tax"))
+ )
+
+ tax_accounts = query.run()
+
for (
name,
parent,
description,
item_wise_tax_detail,
+ account_head,
charge_type,
add_deduct_tax,
tax_amount,
@@ -540,7 +560,11 @@ def get_tax_accounts(
)
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
- {"tax_rate": tax_rate, "tax_amount": tax_value}
+ {
+ "tax_rate": tax_rate,
+ "tax_amount": tax_value,
+ "is_other_charges": 0 if tuple([account_head]) in tax_accounts else 1,
+ }
)
except ValueError:
@@ -583,6 +607,13 @@ def get_tax_accounts(
"options": "currency",
"width": 100,
},
+ {
+ "label": _("Total Other Charges"),
+ "fieldname": "total_other_charges",
+ "fieldtype": "Currency",
+ "options": "currency",
+ "width": 100,
+ },
{
"label": _("Total"),
"fieldname": "total",
diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/__init__.py b/erpnext/accounts/report/payment_ledger/__init__.py
similarity index 100%
rename from erpnext/regional/doctype/ksa_vat_sales_account/__init__.py
rename to erpnext/accounts/report/payment_ledger/__init__.py
diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.js b/erpnext/accounts/report/payment_ledger/payment_ledger.js
new file mode 100644
index 0000000000..9779844dc9
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.js
@@ -0,0 +1,59 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+function get_filters() {
+ let filters = [
+ {
+ "fieldname":"company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "default": frappe.defaults.get_user_default("Company"),
+ "reqd": 1
+ },
+ {
+ "fieldname":"period_start_date",
+ "label": __("Start Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
+ },
+ {
+ "fieldname":"period_end_date",
+ "label": __("End Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.get_today()
+ },
+ {
+ "fieldname":"account",
+ "label": __("Account"),
+ "fieldtype": "MultiSelectList",
+ "options": "Account",
+ get_data: function(txt) {
+ return frappe.db.get_link_options('Account', txt, {
+ company: frappe.query_report.get_filter_value("company")
+ });
+ }
+ },
+ {
+ "fieldname":"voucher_no",
+ "label": __("Voucher No"),
+ "fieldtype": "Data",
+ "width": 100,
+ },
+ {
+ "fieldname":"against_voucher_no",
+ "label": __("Against Voucher No"),
+ "fieldtype": "Data",
+ "width": 100,
+ },
+
+ ]
+ return filters;
+}
+
+frappe.query_reports["Payment Ledger"] = {
+ "filters": get_filters()
+};
diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.json b/erpnext/accounts/report/payment_ledger/payment_ledger.json
similarity index 51%
rename from erpnext/regional/report/ksa_vat/ksa_vat.json
rename to erpnext/accounts/report/payment_ledger/payment_ledger.json
index 036e260310..716329fbef 100644
--- a/erpnext/regional/report/ksa_vat/ksa_vat.json
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.json
@@ -1,32 +1,32 @@
{
"add_total_row": 0,
"columns": [],
- "creation": "2021-07-13 08:54:38.000949",
- "disable_prepared_report": 1,
- "disabled": 1,
+ "creation": "2022-06-06 08:50:43.933708",
+ "disable_prepared_report": 0,
+ "disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
- "modified": "2021-08-26 04:14:37.202594",
+ "modified": "2022-06-06 08:50:43.933708",
"modified_by": "Administrator",
- "module": "Regional",
- "name": "KSA VAT",
+ "module": "Accounts",
+ "name": "Payment Ledger",
"owner": "Administrator",
- "prepared_report": 1,
- "ref_doctype": "GL Entry",
- "report_name": "KSA VAT",
+ "prepared_report": 0,
+ "ref_doctype": "Payment Ledger Entry",
+ "report_name": "Payment Ledger",
"report_type": "Script Report",
"roles": [
{
- "role": "System Manager"
+ "role": "Accounts User"
},
{
"role": "Accounts Manager"
},
{
- "role": "Accounts User"
+ "role": "Auditor"
}
]
}
\ No newline at end of file
diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.py b/erpnext/accounts/report/payment_ledger/payment_ledger.py
new file mode 100644
index 0000000000..e470c2727e
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.py
@@ -0,0 +1,222 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from collections import OrderedDict
+
+import frappe
+from frappe import _, qb
+from frappe.query_builder import Criterion
+
+
+class PaymentLedger(object):
+ def __init__(self, filters=None):
+ self.filters = filters
+ self.columns, self.data = [], []
+ self.voucher_dict = OrderedDict()
+ self.voucher_amount = []
+ self.ple = qb.DocType("Payment Ledger Entry")
+
+ def init_voucher_dict(self):
+
+ if self.voucher_amount:
+ s = set()
+ # build a set of unique vouchers
+ for ple in self.voucher_amount:
+ key = (ple.voucher_type, ple.voucher_no, ple.party)
+ s.add(key)
+
+ # for each unique vouchers, initialize +/- list
+ for key in s:
+ self.voucher_dict[key] = frappe._dict(increase=list(), decrease=list())
+
+ # for each ple, using against voucher and amount, assign it to +/- list
+ # group by against voucher
+ for ple in self.voucher_amount:
+ against_key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
+ target = None
+ if self.voucher_dict.get(against_key):
+ if ple.amount > 0:
+ target = self.voucher_dict.get(against_key).increase
+ else:
+ target = self.voucher_dict.get(against_key).decrease
+
+ # this if condition will lose unassigned ple entries(against_voucher doc doesn't have ple)
+ # need to somehow include the stray entries as well.
+ if target is not None:
+ entry = frappe._dict(
+ company=ple.company,
+ account=ple.account,
+ party_type=ple.party_type,
+ party=ple.party,
+ voucher_type=ple.voucher_type,
+ voucher_no=ple.voucher_no,
+ against_voucher_type=ple.against_voucher_type,
+ against_voucher_no=ple.against_voucher_no,
+ amount=ple.amount,
+ currency=ple.account_currency,
+ )
+
+ if self.filters.include_account_currency:
+ entry["amount_in_account_currency"] = ple.amount_in_account_currency
+
+ target.append(entry)
+
+ def build_data(self):
+ self.data.clear()
+
+ for value in self.voucher_dict.values():
+ voucher_data = []
+ if value.increase != []:
+ voucher_data.extend(value.increase)
+ if value.decrease != []:
+ voucher_data.extend(value.decrease)
+
+ if voucher_data:
+ # balance row
+ total = 0
+ total_in_account_currency = 0
+
+ for x in voucher_data:
+ total += x.amount
+ if self.filters.include_account_currency:
+ total_in_account_currency += x.amount_in_account_currency
+
+ entry = frappe._dict(
+ against_voucher_no="Outstanding:",
+ amount=total,
+ currency=voucher_data[0].currency,
+ )
+
+ if self.filters.include_account_currency:
+ entry["amount_in_account_currency"] = total_in_account_currency
+
+ voucher_data.append(entry)
+
+ # empty row
+ voucher_data.append(frappe._dict())
+ self.data.extend(voucher_data)
+
+ def build_conditions(self):
+ self.conditions = []
+
+ if self.filters.company:
+ self.conditions.append(self.ple.company == self.filters.company)
+
+ if self.filters.account:
+ self.conditions.append(self.ple.account.isin(self.filters.account))
+
+ if self.filters.period_start_date:
+ self.conditions.append(self.ple.posting_date.gte(self.filters.period_start_date))
+
+ if self.filters.period_end_date:
+ self.conditions.append(self.ple.posting_date.lte(self.filters.period_end_date))
+
+ if self.filters.voucher_no:
+ self.conditions.append(self.ple.voucher_no == self.filters.voucher_no)
+
+ if self.filters.against_voucher_no:
+ self.conditions.append(self.ple.against_voucher_no == self.filters.against_voucher_no)
+
+ def get_data(self):
+ ple = self.ple
+
+ self.build_conditions()
+
+ # fetch data from table
+ self.voucher_amount = (
+ qb.from_(ple)
+ .select(ple.star)
+ .where(ple.delinked == 0)
+ .where(Criterion.all(self.conditions))
+ .run(as_dict=True)
+ )
+
+ def get_columns(self):
+ options = None
+ self.columns.append(
+ dict(label=_("Company"), fieldname="company", fieldtype="data", options=options, width="100")
+ )
+
+ self.columns.append(
+ dict(label=_("Account"), fieldname="account", fieldtype="data", options=options, width="100")
+ )
+
+ self.columns.append(
+ dict(
+ label=_("Party Type"), fieldname="party_type", fieldtype="data", options=options, width="100"
+ )
+ )
+ self.columns.append(
+ dict(label=_("Party"), fieldname="party", fieldtype="data", options=options, width="100")
+ )
+ self.columns.append(
+ dict(
+ label=_("Voucher Type"),
+ fieldname="voucher_type",
+ fieldtype="data",
+ options=options,
+ width="100",
+ )
+ )
+ self.columns.append(
+ dict(
+ label=_("Voucher No"), fieldname="voucher_no", fieldtype="data", options=options, width="100"
+ )
+ )
+ self.columns.append(
+ dict(
+ label=_("Against Voucher Type"),
+ fieldname="against_voucher_type",
+ fieldtype="data",
+ options=options,
+ width="100",
+ )
+ )
+ self.columns.append(
+ dict(
+ label=_("Against Voucher No"),
+ fieldname="against_voucher_no",
+ fieldtype="data",
+ options=options,
+ width="100",
+ )
+ )
+ self.columns.append(
+ dict(
+ label=_("Amount"),
+ fieldname="amount",
+ fieldtype="Currency",
+ options="Company:company:default_currency",
+ width="100",
+ )
+ )
+
+ if self.filters.include_account_currency:
+ self.columns.append(
+ dict(
+ label=_("Amount in Account Currency"),
+ fieldname="amount_in_account_currency",
+ fieldtype="Currency",
+ options="currency",
+ width="100",
+ )
+ )
+ self.columns.append(
+ dict(label=_("Currency"), fieldname="currency", fieldtype="Currency", hidden=True)
+ )
+
+ def run(self):
+ self.get_columns()
+ self.get_data()
+
+ # initialize dictionary and group using against voucher
+ self.init_voucher_dict()
+
+ # convert dictionary to list and add balance rows
+ self.build_data()
+
+ return self.columns, self.data
+
+
+def execute(filters=None):
+ return PaymentLedger(filters).run()
diff --git a/erpnext/accounts/report/payment_ledger/test_payment_ledger.py b/erpnext/accounts/report/payment_ledger/test_payment_ledger.py
new file mode 100644
index 0000000000..5ae9b87cde
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/test_payment_ledger.py
@@ -0,0 +1,65 @@
+import unittest
+
+import frappe
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.report.payment_ledger.payment_ledger import execute
+
+
+class TestPaymentLedger(FrappeTestCase):
+ def setUp(self):
+ self.create_company()
+ self.cleanup()
+
+ def cleanup(self):
+ doctypes = []
+ doctypes.append(qb.DocType("GL Entry"))
+ doctypes.append(qb.DocType("Payment Ledger Entry"))
+ doctypes.append(qb.DocType("Sales Invoice"))
+ doctypes.append(qb.DocType("Payment Entry"))
+
+ for doctype in doctypes:
+ qb.from_(doctype).delete().where(doctype.company == self.company).run()
+
+ def create_company(self):
+ name = "Test Payment Ledger"
+ company = None
+ if frappe.db.exists("Company", name):
+ company = frappe.get_doc("Company", name)
+ else:
+ company = frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": name,
+ "country": "India",
+ "default_currency": "INR",
+ "create_chart_of_accounts_based_on": "Standard Template",
+ "chart_of_accounts": "Standard",
+ }
+ )
+ company = company.save()
+ self.company = company.name
+ self.cost_center = company.cost_center
+ self.warehouse = "All Warehouses" + " - " + company.abbr
+ self.income_account = company.default_income_account
+ self.expense_account = company.default_expense_account
+ self.debit_to = company.default_receivable_account
+
+ def test_unpaid_invoice_outstanding(self):
+ sinv = create_sales_invoice(
+ company=self.company,
+ debit_to=self.debit_to,
+ expense_account=self.expense_account,
+ cost_center=self.cost_center,
+ income_account=self.income_account,
+ warehouse=self.warehouse,
+ )
+ pe = get_payment_entry(sinv.doctype, sinv.name).save().submit()
+
+ filters = frappe._dict({"company": self.company})
+ columns, data = execute(filters=filters)
+ outstanding = [x for x in data if x.get("against_voucher_no") == "Outstanding:"]
+ self.assertEqual(outstanding[0].get("amount"), 0)
diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py
index e8a1e795d9..a05d581207 100644
--- a/erpnext/accounts/report/purchase_register/purchase_register.py
+++ b/erpnext/accounts/report/purchase_register/purchase_register.py
@@ -232,12 +232,12 @@ def get_conditions(filters):
conditions += (
common_condition
- + "and ifnull(`tabPurchase Invoice Item`.{0}, '') in %({0})s)".format(dimension.fieldname)
+ + "and ifnull(`tabPurchase Invoice`.{0}, '') in %({0})s)".format(dimension.fieldname)
)
else:
conditions += (
common_condition
- + "and ifnull(`tabPurchase Invoice Item`.{0}, '') in %({0})s)".format(dimension.fieldname)
+ + "and ifnull(`tabPurchase Invoice`.{0}, '') in %({0})s)".format(dimension.fieldname)
)
return conditions
diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py
index 33bd3c7496..b333901d7b 100644
--- a/erpnext/accounts/report/sales_register/sales_register.py
+++ b/erpnext/accounts/report/sales_register/sales_register.py
@@ -370,7 +370,7 @@ def get_conditions(filters):
where parent=`tabSales Invoice`.name
and ifnull(`tab{table}`.{field}, '') = %({field})s)"""
- conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment")
+ conditions += get_sales_invoice_item_field_condition("mode_of_payment", "Sales Invoice Payment")
conditions += get_sales_invoice_item_field_condition("cost_center")
conditions += get_sales_invoice_item_field_condition("warehouse")
conditions += get_sales_invoice_item_field_condition("brand")
@@ -390,12 +390,12 @@ def get_conditions(filters):
conditions += (
common_condition
- + "and ifnull(`tabSales Invoice Item`.{0}, '') in %({0})s)".format(dimension.fieldname)
+ + "and ifnull(`tabSales Invoice`.{0}, '') in %({0})s)".format(dimension.fieldname)
)
else:
conditions += (
common_condition
- + "and ifnull(`tabSales Invoice Item`.{0}, '') in %({0})s)".format(dimension.fieldname)
+ + "and ifnull(`tabSales Invoice`.{0}, '') in %({0})s)".format(dimension.fieldname)
)
return conditions
diff --git a/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js b/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js
index f81297760e..5dc4c3d1c1 100644
--- a/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js
+++ b/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js
@@ -63,24 +63,6 @@ frappe.query_reports["Supplier Ledger Summary"] = {
"fieldtype": "Link",
"options": "Payment Terms Template"
},
- {
- "fieldname":"territory",
- "label": __("Territory"),
- "fieldtype": "Link",
- "options": "Territory"
- },
- {
- "fieldname":"sales_partner",
- "label": __("Sales Partner"),
- "fieldtype": "Link",
- "options": "Sales Partner"
- },
- {
- "fieldname":"sales_person",
- "label": __("Sales Person"),
- "fieldtype": "Link",
- "options": "Sales Person"
- },
{
"fieldname":"tax_id",
"label": __("Tax Id"),
diff --git a/erpnext/accounts/report/tax_detail/tax_detail.py b/erpnext/accounts/report/tax_detail/tax_detail.py
index ba8d307228..ba733c2d18 100644
--- a/erpnext/accounts/report/tax_detail/tax_detail.py
+++ b/erpnext/accounts/report/tax_detail/tax_detail.py
@@ -234,8 +234,11 @@ def modify_report_columns(doctype, field, column):
if field in ["item_tax_rate", "base_net_amount"]:
return None
- if doctype == "GL Entry" and field in ["debit", "credit"]:
- column.update({"label": _("Amount"), "fieldname": "amount"})
+ if doctype == "GL Entry":
+ if field in ["debit", "credit"]:
+ column.update({"label": _("Amount"), "fieldname": "amount"})
+ elif field == "voucher_type":
+ column.update({"fieldtype": "Data", "options": ""})
if field == "taxes_and_charges":
column.update({"label": _("Taxes and Charges Template")})
diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py
index 08d2008682..c6aa21cc86 100644
--- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py
+++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py
@@ -14,9 +14,17 @@ def execute(filters=None):
filters.naming_series = frappe.db.get_single_value("Buying Settings", "supp_master_name")
columns = get_columns(filters)
- tds_docs, tds_accounts, tax_category_map = get_tds_docs(filters)
+ (
+ tds_docs,
+ tds_accounts,
+ tax_category_map,
+ journal_entry_party_map,
+ invoice_total_map,
+ ) = get_tds_docs(filters)
- res = get_result(filters, tds_docs, tds_accounts, tax_category_map)
+ res = get_result(
+ filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_total_map
+ )
final_result = group_by_supplier_and_category(res)
return columns, final_result
diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
index 16e0ac1de6..bfe2a0fd2b 100644
--- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
+++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
@@ -4,15 +4,24 @@
import frappe
from frappe import _
+from frappe.utils import flt
def execute(filters=None):
validate_filters(filters)
- tds_docs, tds_accounts, tax_category_map, journal_entry_party_map = get_tds_docs(filters)
+ (
+ tds_docs,
+ tds_accounts,
+ tax_category_map,
+ journal_entry_party_map,
+ invoice_net_total_map,
+ ) = get_tds_docs(filters)
columns = get_columns(filters)
- res = get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map)
+ res = get_result(
+ filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_net_total_map
+ )
return columns, res
@@ -22,11 +31,12 @@ def validate_filters(filters):
frappe.throw(_("From Date must be before To Date"))
-def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map):
+def get_result(
+ filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_net_total_map
+):
supplier_map = get_supplier_pan_map()
tax_rate_map = get_tax_rate_map(filters)
gle_map = get_gle_map(tds_docs)
- print(journal_entry_party_map)
out = []
for name, details in gle_map.items():
@@ -51,7 +61,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
if entry.account in tds_accounts:
tds_deducted += entry.credit - entry.debit
- total_amount_credited += entry.credit
+ if invoice_net_total_map.get(name):
+ total_amount_credited = invoice_net_total_map.get(name)
+ else:
+ total_amount_credited += entry.credit
+
+ ## Check if ldc is applied and show rate as per ldc
+ actual_rate = (tds_deducted / total_amount_credited) * 100
+
+ if flt(actual_rate) < flt(rate):
+ rate = actual_rate
if tds_deducted:
row = {
@@ -180,9 +199,10 @@ def get_tds_docs(filters):
purchase_invoices = []
payment_entries = []
journal_entries = []
- tax_category_map = {}
- or_filters = {}
- journal_entry_party_map = {}
+ tax_category_map = frappe._dict()
+ invoice_net_total_map = frappe._dict()
+ or_filters = frappe._dict()
+ journal_entry_party_map = frappe._dict()
bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name")
tds_accounts = frappe.get_all(
@@ -219,16 +239,22 @@ def get_tds_docs(filters):
tds_documents.append(d.voucher_no)
if purchase_invoices:
- get_tax_category_map(purchase_invoices, "Purchase Invoice", tax_category_map)
+ get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, invoice_net_total_map)
if payment_entries:
- get_tax_category_map(payment_entries, "Payment Entry", tax_category_map)
+ get_doc_info(payment_entries, "Payment Entry", tax_category_map)
if journal_entries:
journal_entry_party_map = get_journal_entry_party_map(journal_entries)
- get_tax_category_map(journal_entries, "Journal Entry", tax_category_map)
+ get_doc_info(journal_entries, "Journal Entry", tax_category_map)
- return tds_documents, tds_accounts, tax_category_map, journal_entry_party_map
+ return (
+ tds_documents,
+ tds_accounts,
+ tax_category_map,
+ journal_entry_party_map,
+ invoice_net_total_map,
+ )
def get_journal_entry_party_map(journal_entries):
@@ -245,17 +271,18 @@ def get_journal_entry_party_map(journal_entries):
return journal_entry_party_map
-def get_tax_category_map(vouchers, doctype, tax_category_map):
- tax_category_map.update(
- frappe._dict(
- frappe.get_all(
- doctype,
- filters={"name": ("in", vouchers)},
- fields=["name", "tax_withholding_category"],
- as_list=1,
- )
- )
- )
+def get_doc_info(vouchers, doctype, tax_category_map, invoice_net_total_map=None):
+ if doctype == "Purchase Invoice":
+ fields = ["name", "tax_withholding_category", "base_tax_withholding_net_total"]
+ else:
+ fields = ["name", "tax_withholding_category"]
+
+ entries = frappe.get_all(doctype, filters={"name": ("in", vouchers)}, fields=fields)
+
+ for entry in entries:
+ tax_category_map.update({entry.name: entry.tax_withholding_category})
+ if doctype == "Purchase Invoice":
+ invoice_net_total_map.update({entry.name: entry.base_tax_withholding_net_total})
def get_tax_rate_map(filters):
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py
index 6bd08ad837..3af01fde7d 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.py
+++ b/erpnext/accounts/report/trial_balance/trial_balance.py
@@ -38,7 +38,7 @@ def validate_filters(filters):
if not filters.fiscal_year:
frappe.throw(_("Fiscal Year {0} is required").format(filters.fiscal_year))
- fiscal_year = frappe.db.get_value(
+ fiscal_year = frappe.get_cached_value(
"Fiscal Year", filters.fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
)
if not fiscal_year:
@@ -172,11 +172,12 @@ def get_rootwise_opening_balances(filters, report_type):
query_filters = {
"company": filters.company,
"from_date": filters.from_date,
+ "to_date": filters.to_date,
"report_type": report_type,
"year_start_date": filters.year_start_date,
"project": filters.project,
"finance_book": filters.finance_book,
- "company_fb": frappe.db.get_value("Company", filters.company, "default_finance_book"),
+ "company_fb": frappe.get_cached_value("Company", filters.company, "default_finance_book"),
}
if accounting_dimensions:
@@ -200,7 +201,7 @@ def get_rootwise_opening_balances(filters, report_type):
where
company=%(company)s
{additional_conditions}
- and (posting_date < %(from_date)s or ifnull(is_opening, 'No') = 'Yes')
+ and (posting_date < %(from_date)s or (ifnull(is_opening, 'No') = 'Yes' and posting_date <= %(to_date)s))
and account in (select name from `tabAccount` where report_type=%(report_type)s)
and is_cancelled = 0
group by account""".format(
diff --git a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
index 5fcfdff6f1..ee223484d4 100644
--- a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
+++ b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
@@ -104,12 +104,17 @@ def get_opening_balances(filters):
where company=%(company)s
and is_cancelled=0
and ifnull(party_type, '') = %(party_type)s and ifnull(party, '') != ''
- and (posting_date < %(from_date)s or ifnull(is_opening, 'No') = 'Yes')
+ and (posting_date < %(from_date)s or (ifnull(is_opening, 'No') = 'Yes' and posting_date <= %(to_date)s))
{account_filter}
group by party""".format(
account_filter=account_filter
),
- {"company": filters.company, "from_date": filters.from_date, "party_type": filters.party_type},
+ {
+ "company": filters.company,
+ "from_date": filters.from_date,
+ "to_date": filters.to_date,
+ "party_type": filters.party_type,
+ },
as_dict=True,
)
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index eed5836773..97cc1c4a13 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -28,7 +28,7 @@ def get_currency(filters):
filters["presentation_currency"] if filters.get("presentation_currency") else company_currency
)
- report_date = filters.get("to_date")
+ report_date = filters.get("to_date") or filters.get("period_end_date")
if not report_date:
fiscal_year_to_date = get_from_and_to_date(filters.get("to_fiscal_year"))["to_date"]
@@ -101,11 +101,8 @@ def convert_to_presentation_currency(gl_entries, currency_info, company):
account_currency = entry["account_currency"]
if len(account_currencies) == 1 and account_currency == presentation_currency:
- if debit_in_account_currency:
- entry["debit"] = debit_in_account_currency
-
- if credit_in_account_currency:
- entry["credit"] = credit_in_account_currency
+ entry["debit"] = debit_in_account_currency
+ entry["credit"] = credit_in_account_currency
else:
date = currency_info["report_date"]
converted_debit_value = convert(debit, presentation_currency, company_currency, date)
diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py
index 882cd694a3..3aca60eae5 100644
--- a/erpnext/accounts/test/test_utils.py
+++ b/erpnext/accounts/test/test_utils.py
@@ -3,11 +3,14 @@ import unittest
import frappe
from frappe.test_runner import make_test_objects
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.party import get_party_shipping_address
from erpnext.accounts.utils import (
get_future_stock_vouchers,
get_voucherwise_gl_entries,
sort_stock_vouchers_by_posting_date,
+ update_reference_in_payment_entry,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -73,6 +76,47 @@ class TestUtils(unittest.TestCase):
sorted_vouchers = sort_stock_vouchers_by_posting_date(list(reversed(vouchers)))
self.assertEqual(sorted_vouchers, vouchers)
+ def test_update_reference_in_payment_entry(self):
+ item = make_item().name
+
+ purchase_invoice = make_purchase_invoice(
+ item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32
+ )
+ purchase_invoice.submit()
+
+ payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
+ payment_entry.target_exchange_rate = 62.9
+ payment_entry.paid_amount = 15725
+ payment_entry.deductions = []
+ payment_entry.insert()
+
+ self.assertEqual(payment_entry.difference_amount, -4855.00)
+ payment_entry.references = []
+ payment_entry.submit()
+
+ payment_reconciliation = frappe.new_doc("Payment Reconciliation")
+ payment_reconciliation.company = payment_entry.company
+ payment_reconciliation.party_type = "Supplier"
+ payment_reconciliation.party = purchase_invoice.supplier
+ payment_reconciliation.receivable_payable_account = payment_entry.paid_to
+ payment_reconciliation.get_unreconciled_entries()
+ payment_reconciliation.allocate_entries(
+ {
+ "payments": [d.__dict__ for d in payment_reconciliation.payments],
+ "invoices": [d.__dict__ for d in payment_reconciliation.invoices],
+ }
+ )
+ for d in payment_reconciliation.invoices:
+ # Reset invoice outstanding_amount because allocate_entries will zero this value out.
+ d.outstanding_amount = d.amount
+ for d in payment_reconciliation.allocation:
+ d.difference_account = "Exchange Gain/Loss - _TC"
+ payment_reconciliation.reconcile()
+
+ payment_entry.load_from_db()
+ self.assertEqual(len(payment_entry.references), 1)
+ self.assertEqual(payment_entry.difference_amount, 0)
+
ADDRESS_RECORDS = [
{
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 9dafef74f4..2608c03ffe 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -86,7 +86,7 @@ def get_fiscal_years(
)
)
- query = query.orderby(FY.year_start_date, Order.desc)
+ query = query.orderby(FY.year_start_date, order=Order.desc)
fiscal_years = query.run(as_dict=True)
frappe.cache().hset("fiscal_years", company, fiscal_years)
@@ -439,8 +439,7 @@ def reconcile_against_document(args): # nosemgrep
# cancel advance entry
doc = frappe.get_doc(voucher_type, voucher_no)
frappe.flags.ignore_party_validation = True
- gl_map = doc.build_gl_map()
- create_payment_ledger_entry(gl_map, cancel=1, adv_adj=1)
+ _delete_pl_entries(voucher_type, voucher_no)
for entry in entries:
check_if_advance_entry_modified(entry)
@@ -452,18 +451,26 @@ def reconcile_against_document(args): # nosemgrep
else:
update_reference_in_payment_entry(entry, doc, do_not_save=True)
+ if doc.doctype == "Journal Entry":
+ try:
+ doc.validate_total_debit_and_credit()
+ except Exception as validation_exception:
+ raise frappe.ValidationError(_(f"Validation Error for {doc.name}")) from validation_exception
+
doc.save(ignore_permissions=True)
# re-submit advance entry
doc = frappe.get_doc(entry.voucher_type, entry.voucher_no)
gl_map = doc.build_gl_map()
- create_payment_ledger_entry(gl_map, cancel=0, adv_adj=1)
+ create_payment_ledger_entry(gl_map, update_outstanding="No", cancel=0, adv_adj=1)
+
+ # Only update outstanding for newly linked vouchers
+ for entry in entries:
+ update_voucher_outstanding(
+ entry.against_voucher_type, entry.against_voucher, entry.account, entry.party_type, entry.party
+ )
frappe.flags.ignore_party_validation = False
- if entry.voucher_type in ("Payment Entry", "Journal Entry"):
- if hasattr(doc, "update_expense_claim"):
- doc.update_expense_claim()
-
def check_if_advance_entry_modified(args):
"""
@@ -615,11 +622,6 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False):
new_row.docstatus = 1
new_row.update(reference_details)
- payment_entry.flags.ignore_validate_update_after_submit = True
- payment_entry.setup_party_account_field()
- payment_entry.set_missing_values()
- payment_entry.set_amounts()
-
if d.difference_amount and d.difference_account:
account_details = {
"account": d.difference_account,
@@ -631,6 +633,11 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False):
payment_entry.set_gain_or_loss(account_details=account_details)
+ payment_entry.flags.ignore_validate_update_after_submit = True
+ payment_entry.setup_party_account_field()
+ payment_entry.set_missing_values()
+ payment_entry.set_amounts()
+
if not do_not_save:
payment_entry.save(ignore_permissions=True)
@@ -648,6 +655,16 @@ def unlink_ref_doc_from_payment_entries(ref_doc):
(now(), frappe.session.user, ref_doc.doctype, ref_doc.name),
)
+ ple = qb.DocType("Payment Ledger Entry")
+
+ qb.update(ple).set(ple.against_voucher_type, ple.voucher_type).set(
+ ple.against_voucher_no, ple.voucher_no
+ ).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where(
+ (ple.against_voucher_type == ref_doc.doctype)
+ & (ple.against_voucher_no == ref_doc.name)
+ & (ple.delinked == 0)
+ ).run()
+
if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
ref_doc.set("advances", [])
@@ -823,7 +840,14 @@ def get_held_invoices(party_type, party):
def get_outstanding_invoices(
- party_type, party, account, common_filter=None, min_outstanding=None, max_outstanding=None
+ party_type,
+ party,
+ account,
+ common_filter=None,
+ posting_date=None,
+ min_outstanding=None,
+ max_outstanding=None,
+ accounting_dimensions=None,
):
ple = qb.DocType("Payment Ledger Entry")
@@ -850,9 +874,11 @@ def get_outstanding_invoices(
ple_query = QueryPaymentLedger()
invoice_list = ple_query.get_voucher_outstandings(
common_filter=common_filter,
+ posting_date=posting_date,
min_outstanding=min_outstanding,
max_outstanding=max_outstanding,
get_invoices=True,
+ accounting_dimensions=accounting_dimensions or [],
)
for d in invoice_list:
@@ -962,7 +988,7 @@ def get_account_balances(accounts, company):
def create_payment_gateway_account(gateway, payment_channel="Email"):
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account
- company = frappe.db.get_value("Global Defaults", None, "default_company")
+ company = frappe.get_cached_value("Global Defaults", "Global Defaults", "default_company")
if not company:
return
@@ -1030,7 +1056,7 @@ def update_cost_center(docname, cost_center_name, cost_center_number, company, m
frappe.db.set_value("Cost Center", docname, "cost_center_name", cost_center_name.strip())
- new_name = get_autoname_with_number(cost_center_number, cost_center_name, docname, company)
+ new_name = get_autoname_with_number(cost_center_number, cost_center_name, company)
if docname != new_name:
frappe.rename_doc("Cost Center", docname, new_name, force=1, merge=merge)
return new_name
@@ -1053,16 +1079,14 @@ def validate_field_number(doctype_name, docname, number_value, company, field_na
)
-def get_autoname_with_number(number_value, doc_title, name, company):
+def get_autoname_with_number(number_value, doc_title, company):
"""append title with prefix as number and suffix as company's abbreviation separated by '-'"""
- if name:
- name_split = name.split("-")
- parts = [doc_title.strip(), name_split[len(name_split) - 1].strip()]
- else:
- abbr = frappe.get_cached_value("Company", company, ["abbr"], as_dict=True)
- parts = [doc_title.strip(), abbr.abbr]
+ company_abbr = frappe.get_cached_value("Company", company, "abbr")
+ parts = [doc_title.strip(), company_abbr]
+
if cstr(number_value).strip():
parts.insert(0, cstr(number_value).strip())
+
return " - ".join(parts)
@@ -1135,10 +1159,10 @@ def repost_gle_for_stock_vouchers(
if not existing_gle or not compare_existing_and_expected_gle(
existing_gle, expected_gle, precision
):
- _delete_gl_entries(voucher_type, voucher_no)
+ _delete_accounting_ledger_entries(voucher_type, voucher_no)
voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True)
else:
- _delete_gl_entries(voucher_type, voucher_no)
+ _delete_accounting_ledger_entries(voucher_type, voucher_no)
if not frappe.flags.in_test:
frappe.db.commit()
@@ -1150,12 +1174,26 @@ def repost_gle_for_stock_vouchers(
)
+def _delete_pl_entries(voucher_type, voucher_no):
+ ple = qb.DocType("Payment Ledger Entry")
+ qb.from_(ple).delete().where(
+ (ple.voucher_type == voucher_type) & (ple.voucher_no == voucher_no)
+ ).run()
+
+
def _delete_gl_entries(voucher_type, voucher_no):
- frappe.db.sql(
- """delete from `tabGL Entry`
- where voucher_type=%s and voucher_no=%s""",
- (voucher_type, voucher_no),
- )
+ gle = qb.DocType("GL Entry")
+ qb.from_(gle).delete().where(
+ (gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no)
+ ).run()
+
+
+def _delete_accounting_ledger_entries(voucher_type, voucher_no):
+ """
+ Remove entries from both General and Payment Ledger for specified Voucher
+ """
+ _delete_gl_entries(voucher_type, voucher_no)
+ _delete_pl_entries(voucher_type, voucher_no)
def sort_stock_vouchers_by_posting_date(
@@ -1353,9 +1391,8 @@ def check_and_delete_linked_reports(report):
frappe.delete_doc("Desktop Icon", icon)
-def create_payment_ledger_entry(
- gl_entries, cancel=0, adv_adj=0, update_outstanding="Yes", from_repost=0
-):
+def get_payment_ledger_entries(gl_entries, cancel=0):
+ ple_map = []
if gl_entries:
ple = None
@@ -1395,43 +1432,57 @@ def create_payment_ledger_entry(
dr_or_cr *= -1
dr_or_cr_account_currency *= -1
- ple = frappe.get_doc(
- {
- "doctype": "Payment Ledger Entry",
- "posting_date": gle.posting_date,
- "company": gle.company,
- "account_type": account_type,
- "account": gle.account,
- "party_type": gle.party_type,
- "party": gle.party,
- "cost_center": gle.cost_center,
- "finance_book": gle.finance_book,
- "due_date": gle.due_date,
- "voucher_type": gle.voucher_type,
- "voucher_no": gle.voucher_no,
- "against_voucher_type": gle.against_voucher_type
- if gle.against_voucher_type
- else gle.voucher_type,
- "against_voucher_no": gle.against_voucher if gle.against_voucher else gle.voucher_no,
- "account_currency": gle.account_currency,
- "amount": dr_or_cr,
- "amount_in_account_currency": dr_or_cr_account_currency,
- "delinked": True if cancel else False,
- }
+ ple = frappe._dict(
+ doctype="Payment Ledger Entry",
+ posting_date=gle.posting_date,
+ company=gle.company,
+ account_type=account_type,
+ account=gle.account,
+ party_type=gle.party_type,
+ party=gle.party,
+ cost_center=gle.cost_center,
+ finance_book=gle.finance_book,
+ due_date=gle.due_date,
+ voucher_type=gle.voucher_type,
+ voucher_no=gle.voucher_no,
+ against_voucher_type=gle.against_voucher_type
+ if gle.against_voucher_type
+ else gle.voucher_type,
+ against_voucher_no=gle.against_voucher if gle.against_voucher else gle.voucher_no,
+ account_currency=gle.account_currency,
+ amount=dr_or_cr,
+ amount_in_account_currency=dr_or_cr_account_currency,
+ delinked=True if cancel else False,
+ remarks=gle.remarks,
)
dimensions_and_defaults = get_dimensions()
if dimensions_and_defaults:
for dimension in dimensions_and_defaults[0]:
- ple.set(dimension.fieldname, gle.get(dimension.fieldname))
+ ple[dimension.fieldname] = gle.get(dimension.fieldname)
- if cancel:
- delink_original_entry(ple)
- ple.flags.ignore_permissions = 1
- ple.flags.adv_adj = adv_adj
- ple.flags.from_repost = from_repost
- ple.flags.update_outstanding = update_outstanding
- ple.submit()
+ ple_map.append(ple)
+ return ple_map
+
+
+def create_payment_ledger_entry(
+ gl_entries, cancel=0, adv_adj=0, update_outstanding="Yes", from_repost=0
+):
+ if gl_entries:
+ ple_map = get_payment_ledger_entries(gl_entries, cancel=cancel)
+
+ for entry in ple_map:
+
+ ple = frappe.get_doc(entry)
+
+ if cancel:
+ delink_original_entry(ple)
+
+ ple.flags.ignore_permissions = 1
+ ple.flags.adv_adj = adv_adj
+ ple.flags.from_repost = from_repost
+ ple.flags.update_outstanding = update_outstanding
+ ple.submit()
def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, party):
@@ -1451,14 +1502,22 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa
# on cancellation outstanding can be an empty list
voucher_outstanding = ple_query.get_voucher_outstandings(vouchers, common_filter=common_filter)
- if voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"] and voucher_outstanding:
+ if (
+ voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"]
+ and party_type
+ and party
+ and voucher_outstanding
+ ):
outstanding = voucher_outstanding[0]
ref_doc = frappe.get_doc(voucher_type, voucher_no)
# Didn't use db_set for optimisation purpose
- ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"]
+ ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] or 0.0
frappe.db.set_value(
- voucher_type, voucher_no, "outstanding_amount", outstanding["outstanding_in_account_currency"]
+ voucher_type,
+ voucher_no,
+ "outstanding_amount",
+ outstanding["outstanding_in_account_currency"] or 0.0,
)
ref_doc.set_status(update=True)
@@ -1501,6 +1560,7 @@ class QueryPaymentLedger(object):
# query filters
self.vouchers = []
self.common_filter = []
+ self.voucher_posting_date = []
self.min_outstanding = None
self.max_outstanding = None
@@ -1571,6 +1631,8 @@ class QueryPaymentLedger(object):
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
.where(Criterion.all(self.common_filter))
+ .where(Criterion.all(self.dimensions_filter))
+ .where(Criterion.all(self.voucher_posting_date))
.groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
)
@@ -1652,10 +1714,12 @@ class QueryPaymentLedger(object):
self,
vouchers=None,
common_filter=None,
+ posting_date=None,
min_outstanding=None,
max_outstanding=None,
get_payments=False,
get_invoices=False,
+ accounting_dimensions=None,
):
"""
Fetch voucher amount and outstanding amount from Payment Ledger using Database CTE
@@ -1671,6 +1735,8 @@ class QueryPaymentLedger(object):
self.reset()
self.vouchers = vouchers
self.common_filter = common_filter or []
+ self.dimensions_filter = accounting_dimensions or []
+ self.voucher_posting_date = posting_date or []
self.min_outstanding = min_outstanding
self.max_outstanding = max_outstanding
self.get_payments = get_payments
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index f414930d72..4951385136 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -76,7 +76,6 @@ frappe.ui.form.on('Asset', {
refresh: function(frm) {
frappe.ui.form.trigger("Asset", "is_existing_asset");
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
- frm.events.make_schedules_editable(frm);
if (frm.doc.docstatus==1) {
if (in_list(["Submitted", "Partially Depreciated", "Fully Depreciated"], frm.doc.status)) {
@@ -136,6 +135,10 @@ frappe.ui.form.on('Asset', {
}, __("Manage"));
}
+ if (frm.doc.depr_entry_posting_status === "Failed") {
+ frm.trigger("set_depr_posting_failure_alert");
+ }
+
frm.trigger("setup_chart");
}
@@ -146,6 +149,19 @@ frappe.ui.form.on('Asset', {
}
},
+ set_depr_posting_failure_alert: function (frm) {
+ const alert = `
+
+
+
+ Failed to post depreciation entries
+
+
+ `;
+
+ frm.dashboard.set_headline_alert(alert);
+ },
+
toggle_reference_doc: function(frm) {
if (frm.doc.purchase_receipt && frm.doc.purchase_invoice && frm.doc.docstatus === 1) {
frm.set_df_property('purchase_invoice', 'read_only', 1);
@@ -188,39 +204,67 @@ frappe.ui.form.on('Asset', {
})
},
- setup_chart: function(frm) {
- var x_intervals = [frm.doc.purchase_date];
- var asset_values = [frm.doc.gross_purchase_amount];
- var last_depreciation_date = frm.doc.purchase_date;
-
- if(frm.doc.opening_accumulated_depreciation) {
- last_depreciation_date = frappe.datetime.add_months(frm.doc.next_depreciation_date,
- -1*frm.doc.frequency_of_depreciation);
-
- x_intervals.push(last_depreciation_date);
- asset_values.push(flt(frm.doc.gross_purchase_amount) -
- flt(frm.doc.opening_accumulated_depreciation));
+ setup_chart: async function(frm) {
+ if(frm.doc.finance_books.length > 1) {
+ return
}
- $.each(frm.doc.schedules || [], function(i, v) {
- x_intervals.push(v.schedule_date);
- var asset_value = flt(frm.doc.gross_purchase_amount) - flt(v.accumulated_depreciation_amount);
- if(v.journal_entry) {
- last_depreciation_date = v.schedule_date;
- asset_values.push(asset_value);
- } else {
- if (in_list(["Scrapped", "Sold"], frm.doc.status)) {
- asset_values.push(null);
- } else {
- asset_values.push(asset_value)
- }
+ var x_intervals = [frappe.format(frm.doc.purchase_date, { fieldtype: 'Date' })];
+ var asset_values = [frm.doc.gross_purchase_amount];
+
+ if(frm.doc.calculate_depreciation) {
+ if(frm.doc.opening_accumulated_depreciation) {
+ var depreciation_date = frappe.datetime.add_months(
+ frm.doc.finance_books[0].depreciation_start_date,
+ -1 * frm.doc.finance_books[0].frequency_of_depreciation
+ );
+ x_intervals.push(frappe.format(depreciation_date, { fieldtype: 'Date' }));
+ asset_values.push(flt(frm.doc.gross_purchase_amount - frm.doc.opening_accumulated_depreciation, precision('gross_purchase_amount')));
}
- });
+
+ let depr_schedule = (await frappe.call(
+ "erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule.get_depr_schedule",
+ {
+ asset_name: frm.doc.name,
+ status: frm.doc.docstatus ? "Active" : "Draft",
+ finance_book: frm.doc.finance_books[0].finance_book || null
+ }
+ )).message;
+
+ $.each(depr_schedule || [], function(i, v) {
+ x_intervals.push(frappe.format(v.schedule_date, { fieldtype: 'Date' }));
+ var asset_value = flt(frm.doc.gross_purchase_amount - v.accumulated_depreciation_amount, precision('gross_purchase_amount'));
+ if(v.journal_entry) {
+ asset_values.push(asset_value);
+ } else {
+ if (in_list(["Scrapped", "Sold"], frm.doc.status)) {
+ asset_values.push(null);
+ } else {
+ asset_values.push(asset_value)
+ }
+ }
+ });
+ } else {
+ if(frm.doc.opening_accumulated_depreciation) {
+ x_intervals.push(frappe.format(frm.doc.creation.split(" ")[0], { fieldtype: 'Date' }));
+ asset_values.push(flt(frm.doc.gross_purchase_amount - frm.doc.opening_accumulated_depreciation, precision('gross_purchase_amount')));
+ }
+
+ let depr_entries = (await frappe.call({
+ method: "get_manual_depreciation_entries",
+ doc: frm.doc,
+ })).message;
+
+ $.each(depr_entries || [], function(i, v) {
+ x_intervals.push(frappe.format(v.posting_date, { fieldtype: 'Date' }));
+ let last_asset_value = asset_values[asset_values.length - 1]
+ asset_values.push(flt(last_asset_value - v.value, precision('gross_purchase_amount')));
+ });
+ }
if(in_list(["Scrapped", "Sold"], frm.doc.status)) {
- x_intervals.push(frm.doc.disposal_date);
+ x_intervals.push(frappe.format(frm.doc.disposal_date, { fieldtype: 'Date' }));
asset_values.push(0);
- last_depreciation_date = frm.doc.disposal_date;
}
frm.dashboard.render_graph({
@@ -230,7 +274,7 @@ frappe.ui.form.on('Asset', {
datasets: [{
color: 'green',
values: asset_values,
- formatted: asset_values.map(d => d.toFixed(2))
+ formatted: asset_values.map(d => d?.toFixed(2))
}]
},
type: 'line'
@@ -239,8 +283,10 @@ frappe.ui.form.on('Asset', {
item_code: function(frm) {
- if(frm.doc.item_code) {
+ if(frm.doc.item_code && frm.doc.calculate_depreciation) {
frm.trigger('set_finance_book');
+ } else {
+ frm.set_value('finance_books', []);
}
},
@@ -264,21 +310,6 @@ frappe.ui.form.on('Asset', {
// frm.toggle_reqd("next_depreciation_date", (!frm.doc.is_existing_asset && frm.doc.calculate_depreciation));
},
- opening_accumulated_depreciation: function(frm) {
- erpnext.asset.set_accumulated_depreciation(frm);
- },
-
- make_schedules_editable: function(frm) {
- if (frm.doc.finance_books) {
- var is_editable = frm.doc.finance_books.filter(d => d.depreciation_method == "Manual").length > 0
- ? true : false;
-
- frm.toggle_enable("schedules", is_editable);
- frm.fields_dict["schedules"].grid.toggle_enable("schedule_date", is_editable);
- frm.fields_dict["schedules"].grid.toggle_enable("depreciation_amount", is_editable);
- }
- },
-
make_sales_invoice: function(frm) {
frappe.call({
args: {
@@ -381,6 +412,11 @@ frappe.ui.form.on('Asset', {
calculate_depreciation: function(frm) {
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
+ if (frm.doc.item_code && frm.doc.calculate_depreciation ) {
+ frm.trigger("set_finance_book");
+ } else {
+ frm.set_value("finance_books", []);
+ }
},
gross_purchase_amount: function(frm) {
@@ -425,7 +461,11 @@ frappe.ui.form.on('Asset', {
set_values_from_purchase_doc: function(frm, doctype, purchase_doc) {
frm.set_value('company', purchase_doc.company);
- frm.set_value('purchase_date', purchase_doc.posting_date);
+ if (purchase_doc.bill_date) {
+ frm.set_value('purchase_date', purchase_doc.bill_date);
+ } else {
+ frm.set_value('purchase_date', purchase_doc.posting_date);
+ }
const item = purchase_doc.items.find(item => item.item_code === frm.doc.item_code);
if (!item) {
doctype_field = frappe.scrub(doctype)
@@ -465,7 +505,6 @@ frappe.ui.form.on('Asset Finance Book', {
depreciation_method: function(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frm.events.set_depreciation_rate(frm, row);
- frm.events.make_schedules_editable(frm);
},
expected_value_after_useful_life: function(frm, cdt, cdn) {
@@ -501,41 +540,6 @@ frappe.ui.form.on('Asset Finance Book', {
}
});
-frappe.ui.form.on('Depreciation Schedule', {
- make_depreciation_entry: function(frm, cdt, cdn) {
- var row = locals[cdt][cdn];
- if (!row.journal_entry) {
- frappe.call({
- method: "erpnext.assets.doctype.asset.depreciation.make_depreciation_entry",
- args: {
- "asset_name": frm.doc.name,
- "date": row.schedule_date
- },
- callback: function(r) {
- frappe.model.sync(r.message);
- frm.refresh();
- }
- })
- }
- },
-
- depreciation_amount: function(frm, cdt, cdn) {
- erpnext.asset.set_accumulated_depreciation(frm);
- }
-
-})
-
-erpnext.asset.set_accumulated_depreciation = function(frm) {
- if(frm.doc.depreciation_method != "Manual") return;
-
- var accumulated_depreciation = flt(frm.doc.opening_accumulated_depreciation);
- $.each(frm.doc.schedules || [], function(i, row) {
- accumulated_depreciation += flt(row.depreciation_amount);
- frappe.model.set_value(row.doctype, row.name,
- "accumulated_depreciation_amount", accumulated_depreciation);
- })
-};
-
erpnext.asset.scrap_asset = function(frm) {
frappe.confirm(__("Do you really want to scrap this asset?"), function () {
frappe.call({
diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json
index 991df4eada..ea575fd71f 100644
--- a/erpnext/assets/doctype/asset/asset.json
+++ b/erpnext/assets/doctype/asset/asset.json
@@ -52,8 +52,6 @@
"column_break_24",
"frequency_of_depreciation",
"next_depreciation_date",
- "section_break_14",
- "schedules",
"insurance_details",
"policy_number",
"insurer",
@@ -70,6 +68,7 @@
"column_break_51",
"purchase_receipt_amount",
"default_finance_book",
+ "depr_entry_posting_status",
"amended_from"
],
"fields": [
@@ -307,19 +306,6 @@
"label": "Next Depreciation Date",
"no_copy": 1
},
- {
- "depends_on": "calculate_depreciation",
- "fieldname": "section_break_14",
- "fieldtype": "Section Break",
- "label": "Depreciation Schedule"
- },
- {
- "fieldname": "schedules",
- "fieldtype": "Table",
- "label": "Depreciation Schedule",
- "no_copy": 1,
- "options": "Depreciation Schedule"
- },
{
"collapsible": 1,
"fieldname": "insurance_details",
@@ -388,7 +374,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
- "options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt",
+ "options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nDecapitalized",
"read_only": 1
},
{
@@ -488,6 +474,16 @@
"fieldtype": "Int",
"label": "Asset Quantity",
"read_only_depends_on": "eval:!doc.is_existing_asset"
+ },
+ {
+ "fieldname": "depr_entry_posting_status",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Depreciation Entry Posting Status",
+ "no_copy": 1,
+ "options": "\nSuccessful\nFailed",
+ "print_hide": 1,
+ "read_only": 1
}
],
"idx": 72,
@@ -502,15 +498,26 @@
{
"group": "Repair",
"link_doctype": "Asset Repair",
- "link_fieldname": "asset_name"
+ "link_fieldname": "asset"
},
{
"group": "Value",
"link_doctype": "Asset Value Adjustment",
"link_fieldname": "asset"
+ },
+ {
+ "group": "Depreciation",
+ "link_doctype": "Asset Depreciation Schedule",
+ "link_fieldname": "asset"
+ },
+ {
+ "group": "Journal Entry",
+ "link_doctype": "Journal Entry",
+ "link_fieldname": "reference_name",
+ "table_fieldname": "accounts"
}
],
- "modified": "2022-07-20 10:15:12.887372",
+ "modified": "2023-02-02 00:03:11.706427",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index a22d70dd63..e1d58a0264 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -8,14 +8,15 @@ import math
import frappe
from frappe import _
from frappe.utils import (
- add_days,
add_months,
cint,
date_diff,
flt,
get_datetime,
get_last_day,
+ get_link_to_form,
getdate,
+ is_last_day_of_the_month,
month_diff,
nowdate,
today,
@@ -28,6 +29,15 @@ from erpnext.assets.doctype.asset.depreciation import (
get_disposal_account_and_cost_center,
)
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ cancel_asset_depr_schedules,
+ convert_draft_asset_depr_schedules_into_active,
+ get_asset_depr_schedule_doc,
+ get_depr_schedule,
+ make_draft_asset_depr_schedules,
+ make_draft_asset_depr_schedules_if_not_present,
+ update_draft_asset_depr_schedules,
+)
from erpnext.controllers.accounts_controller import AccountsController
@@ -40,9 +50,9 @@ class Asset(AccountsController):
self.set_missing_values()
if not self.split_from:
self.prepare_depreciation_data()
+ update_draft_asset_depr_schedules(self)
self.validate_gross_and_purchase_amount()
- if self.get("schedules"):
- self.validate_expected_value_after_useful_life()
+ self.validate_expected_value_after_useful_life()
self.status = self.get_status()
@@ -52,16 +62,24 @@ class Asset(AccountsController):
self.make_asset_movement()
if not self.booked_fixed_asset and self.validate_make_gl_entry():
self.make_gl_entries()
+ if not self.split_from:
+ make_draft_asset_depr_schedules_if_not_present(self)
+ convert_draft_asset_depr_schedules_into_active(self)
def on_cancel(self):
self.validate_cancellation()
self.cancel_movement_entries()
self.delete_depreciation_entries()
+ cancel_asset_depr_schedules(self)
self.set_status()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
make_reverse_gl_entries(voucher_type="Asset", voucher_no=self.name)
self.db_set("booked_fixed_asset", 0)
+ def after_insert(self):
+ if not self.split_from:
+ make_draft_asset_depr_schedules(self)
+
def validate_asset_and_reference(self):
if self.purchase_invoice or self.purchase_receipt:
reference_doc = "Purchase Invoice" if self.purchase_invoice else "Purchase Receipt"
@@ -79,12 +97,10 @@ class Asset(AccountsController):
_("Purchase Invoice cannot be made against an existing asset {0}").format(self.name)
)
- def prepare_depreciation_data(self, date_of_sale=None, date_of_return=None):
+ def prepare_depreciation_data(self):
if self.calculate_depreciation:
self.value_after_depreciation = 0
self.set_depreciation_rate()
- self.make_depreciation_schedule(date_of_sale)
- self.set_accumulated_depreciation(date_of_sale, date_of_return)
else:
self.finance_books = []
self.value_after_depreciation = flt(self.gross_purchase_amount) - flt(
@@ -223,211 +239,6 @@ class Asset(AccountsController):
self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation")
)
- def make_depreciation_schedule(self, date_of_sale):
- if "Manual" not in [d.depreciation_method for d in self.finance_books] and not self.get(
- "schedules"
- ):
- self.schedules = []
-
- if not self.available_for_use_date:
- return
-
- start = self.clear_depreciation_schedule()
-
- for finance_book in self.get("finance_books"):
- self._make_depreciation_schedule(finance_book, start, date_of_sale)
-
- def _make_depreciation_schedule(self, finance_book, start, date_of_sale):
- self.validate_asset_finance_books(finance_book)
-
- value_after_depreciation = self._get_value_after_depreciation(finance_book)
- finance_book.value_after_depreciation = value_after_depreciation
-
- number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - cint(
- self.number_of_depreciations_booked
- )
-
- has_pro_rata = self.check_is_pro_rata(finance_book)
- if has_pro_rata:
- number_of_pending_depreciations += 1
-
- skip_row = False
- should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date)
-
- for n in range(start[finance_book.idx - 1], number_of_pending_depreciations):
- # If depreciation is already completed (for double declining balance)
- if skip_row:
- continue
-
- depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
-
- if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
- schedule_date = add_months(
- finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation)
- )
-
- if should_get_last_day:
- schedule_date = get_last_day(schedule_date)
-
- # schedule date will be a year later from start date
- # so monthly schedule date is calculated by removing 11 months from it
- monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1)
-
- # if asset is being sold
- if date_of_sale:
- from_date = self.get_from_date(finance_book.finance_book)
- depreciation_amount, days, months = self.get_pro_rata_amt(
- finance_book, depreciation_amount, from_date, date_of_sale
- )
-
- if depreciation_amount > 0:
- self._add_depreciation_row(
- date_of_sale,
- depreciation_amount,
- finance_book.depreciation_method,
- finance_book.finance_book,
- finance_book.idx,
- )
-
- break
-
- # For first row
- if has_pro_rata and not self.opening_accumulated_depreciation and n == 0:
- from_date = add_days(
- self.available_for_use_date, -1
- ) # needed to calc depr amount for available_for_use_date too
- depreciation_amount, days, months = self.get_pro_rata_amt(
- finance_book, depreciation_amount, from_date, finance_book.depreciation_start_date
- )
-
- # For first depr schedule date will be the start date
- # so monthly schedule date is calculated by removing month difference between use date and start date
- monthly_schedule_date = add_months(finance_book.depreciation_start_date, -months + 1)
-
- # For last row
- elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
- if not self.flags.increase_in_asset_life:
- # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
- self.to_date = add_months(
- self.available_for_use_date,
- (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation),
- )
-
- depreciation_amount_without_pro_rata = depreciation_amount
-
- depreciation_amount, days, months = self.get_pro_rata_amt(
- finance_book, depreciation_amount, schedule_date, self.to_date
- )
-
- depreciation_amount = self.get_adjusted_depreciation_amount(
- depreciation_amount_without_pro_rata, depreciation_amount, finance_book.finance_book
- )
-
- monthly_schedule_date = add_months(schedule_date, 1)
- schedule_date = add_days(schedule_date, days)
- last_schedule_date = schedule_date
-
- if not depreciation_amount:
- continue
- value_after_depreciation -= flt(depreciation_amount, self.precision("gross_purchase_amount"))
-
- # Adjust depreciation amount in the last period based on the expected value after useful life
- if finance_book.expected_value_after_useful_life and (
- (
- n == cint(number_of_pending_depreciations) - 1
- and value_after_depreciation != finance_book.expected_value_after_useful_life
- )
- or value_after_depreciation < finance_book.expected_value_after_useful_life
- ):
- depreciation_amount += value_after_depreciation - finance_book.expected_value_after_useful_life
- skip_row = True
-
- if depreciation_amount > 0:
- self._add_depreciation_row(
- schedule_date,
- depreciation_amount,
- finance_book.depreciation_method,
- finance_book.finance_book,
- finance_book.idx,
- )
-
- def _add_depreciation_row(
- self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id
- ):
- self.append(
- "schedules",
- {
- "schedule_date": schedule_date,
- "depreciation_amount": depreciation_amount,
- "depreciation_method": depreciation_method,
- "finance_book": finance_book,
- "finance_book_id": finance_book_id,
- },
- )
-
- def _get_value_after_depreciation(self, finance_book):
- # value_after_depreciation - current Asset value
- if self.docstatus == 1 and finance_book.value_after_depreciation:
- value_after_depreciation = flt(finance_book.value_after_depreciation)
- else:
- value_after_depreciation = flt(self.gross_purchase_amount) - flt(
- self.opening_accumulated_depreciation
- )
-
- return value_after_depreciation
-
- # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
- # JE: Journal Entry, FB: Finance Book
- def clear_depreciation_schedule(self):
- start = []
- num_of_depreciations_completed = 0
- depr_schedule = []
-
- for schedule in self.get("schedules"):
- # to update start when there are JEs linked with all the schedule rows corresponding to an FB
- if len(start) == (int(schedule.finance_book_id) - 2):
- start.append(num_of_depreciations_completed)
- num_of_depreciations_completed = 0
-
- # to ensure that start will only be updated once for each FB
- if len(start) == (int(schedule.finance_book_id) - 1):
- if schedule.journal_entry:
- num_of_depreciations_completed += 1
- depr_schedule.append(schedule)
- else:
- start.append(num_of_depreciations_completed)
- num_of_depreciations_completed = 0
-
- # to update start when all the schedule rows corresponding to the last FB are linked with JEs
- if len(start) == (len(self.finance_books) - 1):
- start.append(num_of_depreciations_completed)
-
- # when the Depreciation Schedule is being created for the first time
- if start == []:
- start = [0] * len(self.finance_books)
- else:
- self.schedules = depr_schedule
-
- return start
-
- def get_from_date(self, finance_book):
- if not self.get("schedules"):
- return self.available_for_use_date
-
- if len(self.finance_books) == 1:
- return self.schedules[-1].schedule_date
-
- from_date = ""
- for schedule in self.get("schedules"):
- if schedule.finance_book == finance_book:
- from_date = schedule.schedule_date
-
- if from_date:
- return from_date
-
- # since depr for available_for_use_date is not yet booked
- return add_days(self.available_for_use_date, -1)
-
# if it returns True, depreciation_amount will not be equal for the first and last rows
def check_is_pro_rata(self, row):
has_pro_rata = False
@@ -512,83 +323,15 @@ class Asset(AccountsController):
).format(row.idx)
)
- # to ensure that final accumulated depreciation amount is accurate
- def get_adjusted_depreciation_amount(
- self, depreciation_amount_without_pro_rata, depreciation_amount_for_last_row, finance_book
- ):
- if not self.opening_accumulated_depreciation:
- depreciation_amount_for_first_row = self.get_depreciation_amount_for_first_row(finance_book)
-
- if (
- depreciation_amount_for_first_row + depreciation_amount_for_last_row
- != depreciation_amount_without_pro_rata
- ):
- depreciation_amount_for_last_row = (
- depreciation_amount_without_pro_rata - depreciation_amount_for_first_row
- )
-
- return depreciation_amount_for_last_row
-
- def get_depreciation_amount_for_first_row(self, finance_book):
- if self.has_only_one_finance_book():
- return self.schedules[0].depreciation_amount
- else:
- for schedule in self.schedules:
- if schedule.finance_book == finance_book:
- return schedule.depreciation_amount
-
- def has_only_one_finance_book(self):
- if len(self.finance_books) == 1:
- return True
-
- def set_accumulated_depreciation(
- self, date_of_sale=None, date_of_return=None, ignore_booked_entry=False
- ):
- straight_line_idx = [
- d.idx for d in self.get("schedules") if d.depreciation_method == "Straight Line"
- ]
- finance_books = []
-
- for i, d in enumerate(self.get("schedules")):
- if ignore_booked_entry and d.journal_entry:
- continue
-
- if int(d.finance_book_id) not in finance_books:
- accumulated_depreciation = flt(self.opening_accumulated_depreciation)
- value_after_depreciation = flt(self.get_value_after_depreciation(d.finance_book_id))
- finance_books.append(int(d.finance_book_id))
-
- depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount"))
- value_after_depreciation -= flt(depreciation_amount)
-
- # for the last row, if depreciation method = Straight Line
- if (
- straight_line_idx
- and i == max(straight_line_idx) - 1
- and not date_of_sale
- and not date_of_return
- ):
- book = self.get("finance_books")[cint(d.finance_book_id) - 1]
- depreciation_amount += flt(
- value_after_depreciation - flt(book.expected_value_after_useful_life),
- d.precision("depreciation_amount"),
- )
-
- d.depreciation_amount = depreciation_amount
- accumulated_depreciation += d.depreciation_amount
- d.accumulated_depreciation_amount = flt(
- accumulated_depreciation, d.precision("accumulated_depreciation_amount")
- )
-
- def get_value_after_depreciation(self, idx):
- return flt(self.get("finance_books")[cint(idx) - 1].value_after_depreciation)
-
def validate_expected_value_after_useful_life(self):
for row in self.get("finance_books"):
+ depr_schedule = get_depr_schedule(self.name, "Draft", row.finance_book)
+
+ if not depr_schedule:
+ continue
+
accumulated_depreciation_after_full_schedule = [
- d.accumulated_depreciation_amount
- for d in self.get("schedules")
- if cint(d.finance_book_id) == row.idx
+ d.accumulated_depreciation_amount for d in depr_schedule
]
if accumulated_depreciation_after_full_schedule:
@@ -637,15 +380,23 @@ class Asset(AccountsController):
movement.cancel()
def delete_depreciation_entries(self):
- for d in self.get("schedules"):
- if d.journal_entry:
- frappe.get_doc("Journal Entry", d.journal_entry).cancel()
- d.db_set("journal_entry", None)
+ if self.calculate_depreciation:
+ for row in self.get("finance_books"):
+ depr_schedule = get_depr_schedule(self.name, "Active", row.finance_book)
- self.db_set(
- "value_after_depreciation",
- (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)),
- )
+ for d in depr_schedule or []:
+ if d.journal_entry:
+ frappe.get_doc("Journal Entry", d.journal_entry).cancel()
+ else:
+ depr_entries = self.get_manual_depreciation_entries()
+
+ for depr_entry in depr_entries or []:
+ frappe.get_doc("Journal Entry", depr_entry.name).cancel()
+
+ self.db_set(
+ "value_after_depreciation",
+ (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)),
+ )
def set_status(self, status=None):
"""Get and update status"""
@@ -662,11 +413,14 @@ class Asset(AccountsController):
if self.journal_entry_for_scrap:
status = "Scrapped"
- elif self.finance_books:
- idx = self.get_default_finance_book_idx() or 0
+ else:
+ expected_value_after_useful_life = 0
+ value_after_depreciation = self.value_after_depreciation
- expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life
- value_after_depreciation = self.finance_books[idx].value_after_depreciation
+ if self.calculate_depreciation:
+ idx = self.get_default_finance_book_idx() or 0
+ expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life
+ value_after_depreciation = self.finance_books[idx].value_after_depreciation
if flt(value_after_depreciation) <= expected_value_after_useful_life:
status = "Fully Depreciated"
@@ -676,6 +430,19 @@ class Asset(AccountsController):
status = "Cancelled"
return status
+ def get_value_after_depreciation(self, finance_book=None):
+ if not self.calculate_depreciation:
+ return flt(self.value_after_depreciation, self.precision("gross_purchase_amount"))
+
+ if not finance_book:
+ return flt(
+ self.get("finance_books")[0].value_after_depreciation, self.precision("gross_purchase_amount")
+ )
+
+ for row in self.get("finance_books"):
+ if finance_book == row.finance_book:
+ return flt(row.value_after_depreciation, self.precision("gross_purchase_amount"))
+
def get_default_finance_book_idx(self):
if not self.get("default_finance_book") and self.company:
self.default_finance_book = erpnext.get_default_finance_book(self.company)
@@ -685,6 +452,44 @@ class Asset(AccountsController):
if d.finance_book == self.default_finance_book:
return cint(d.idx) - 1
+ @frappe.whitelist()
+ def get_manual_depreciation_entries(self):
+ (_, _, depreciation_expense_account) = get_depreciation_accounts(self)
+
+ gle = frappe.qb.DocType("GL Entry")
+
+ records = (
+ frappe.qb.from_(gle)
+ .select(gle.voucher_no.as_("name"), gle.debit.as_("value"), gle.posting_date)
+ .where(gle.against_voucher == self.name)
+ .where(gle.account == depreciation_expense_account)
+ .where(gle.debit != 0)
+ .where(gle.is_cancelled == 0)
+ .orderby(gle.posting_date)
+ .orderby(gle.creation)
+ ).run(as_dict=True)
+
+ return records
+
+ @erpnext.allow_regional
+ def get_depreciation_amount(self, depreciable_value, fb_row):
+ if fb_row.depreciation_method in ("Straight Line", "Manual"):
+ # if the Depreciation Schedule is being prepared for the first time
+ if not self.flags.increase_in_asset_life:
+ depreciation_amount = (
+ flt(self.gross_purchase_amount) - flt(fb_row.expected_value_after_useful_life)
+ ) / flt(fb_row.total_number_of_depreciations)
+
+ # if the Depreciation Schedule is being modified after Asset Repair
+ else:
+ depreciation_amount = (
+ flt(fb_row.value_after_depreciation) - flt(fb_row.expected_value_after_useful_life)
+ ) / (date_diff(self.to_date, self.available_for_use_date) / 365)
+ else:
+ depreciation_amount = flt(depreciable_value * (flt(fb_row.rate_of_depreciation) / 100))
+
+ return depreciation_amount
+
def validate_make_gl_entry(self):
purchase_document = self.get_purchase_document()
if not purchase_document:
@@ -828,7 +633,9 @@ class Asset(AccountsController):
def update_maintenance_status():
- assets = frappe.get_all("Asset", filters={"docstatus": 1, "maintenance_required": 1})
+ assets = frappe.get_all(
+ "Asset", filters={"docstatus": 1, "maintenance_required": 1, "disposal_date": ("is", "not set")}
+ )
for asset in assets:
asset = frappe.get_doc("Asset", asset.name)
@@ -843,7 +650,6 @@ def update_maintenance_status():
def make_post_gl_entry():
-
asset_categories = frappe.db.get_all("Asset Category", fields=["name", "enable_cwip_accounting"])
for asset_category in asset_categories:
@@ -996,7 +802,7 @@ def make_journal_entry(asset_name):
depreciation_expense_account,
) = get_depreciation_accounts(asset)
- depreciation_cost_center, depreciation_series = frappe.db.get_value(
+ depreciation_cost_center, depreciation_series = frappe.get_cached_value(
"Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
)
depreciation_cost_center = asset.cost_center or depreciation_cost_center
@@ -1061,6 +867,13 @@ def is_cwip_accounting_enabled(asset_category):
return cint(frappe.db.get_value("Asset Category", asset_category, "enable_cwip_accounting"))
+@frappe.whitelist()
+def get_asset_value_after_depreciation(asset_name, finance_book=None):
+ asset = frappe.get_doc("Asset", asset_name)
+
+ return asset.get_value_after_depreciation(finance_book)
+
+
def get_total_days(date, frequency):
period_start_date = add_months(date, cint(frequency) * -1)
@@ -1070,32 +883,6 @@ def get_total_days(date, frequency):
return date_diff(date, period_start_date)
-def is_last_day_of_the_month(date):
- last_day_of_the_month = get_last_day(date)
-
- return getdate(last_day_of_the_month) == getdate(date)
-
-
-@erpnext.allow_regional
-def get_depreciation_amount(asset, depreciable_value, row):
- if row.depreciation_method in ("Straight Line", "Manual"):
- # if the Depreciation Schedule is being prepared for the first time
- if not asset.flags.increase_in_asset_life:
- depreciation_amount = (
- flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life)
- ) / flt(row.total_number_of_depreciations)
-
- # if the Depreciation Schedule is being modified after Asset Repair
- else:
- depreciation_amount = (
- flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)
- ) / (date_diff(asset.to_date, asset.available_for_use_date) / 365)
- else:
- depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100))
-
- return depreciation_amount
-
-
@frappe.whitelist()
def split_asset(asset_name, split_qty):
asset = frappe.get_doc("Asset", asset_name)
@@ -1107,12 +894,12 @@ def split_asset(asset_name, split_qty):
remaining_qty = asset.asset_quantity - split_qty
new_asset = create_new_asset_after_split(asset, split_qty)
- update_existing_asset(asset, remaining_qty)
+ update_existing_asset(asset, remaining_qty, new_asset.name)
return new_asset
-def update_existing_asset(asset, remaining_qty):
+def update_existing_asset(asset, remaining_qty, new_asset_name):
remaining_gross_purchase_amount = flt(
(asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity
)
@@ -1130,34 +917,49 @@ def update_existing_asset(asset, remaining_qty):
},
)
- for finance_book in asset.get("finance_books"):
+ for row in asset.get("finance_books"):
value_after_depreciation = flt(
- (finance_book.value_after_depreciation * remaining_qty) / asset.asset_quantity
+ (row.value_after_depreciation * remaining_qty) / asset.asset_quantity
)
expected_value_after_useful_life = flt(
- (finance_book.expected_value_after_useful_life * remaining_qty) / asset.asset_quantity
+ (row.expected_value_after_useful_life * remaining_qty) / asset.asset_quantity
)
frappe.db.set_value(
- "Asset Finance Book", finance_book.name, "value_after_depreciation", value_after_depreciation
+ "Asset Finance Book", row.name, "value_after_depreciation", value_after_depreciation
)
frappe.db.set_value(
"Asset Finance Book",
- finance_book.name,
+ row.name,
"expected_value_after_useful_life",
expected_value_after_useful_life,
)
- accumulated_depreciation = 0
+ current_asset_depr_schedule_doc = get_asset_depr_schedule_doc(
+ asset.name, "Active", row.finance_book
+ )
+ new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc)
- for term in asset.get("schedules"):
- depreciation_amount = flt((term.depreciation_amount * remaining_qty) / asset.asset_quantity)
- frappe.db.set_value(
- "Depreciation Schedule", term.name, "depreciation_amount", depreciation_amount
- )
- accumulated_depreciation += depreciation_amount
- frappe.db.set_value(
- "Depreciation Schedule", term.name, "accumulated_depreciation_amount", accumulated_depreciation
+ new_asset_depr_schedule_doc.set_draft_asset_depr_schedule_details(asset, row)
+
+ accumulated_depreciation = 0
+
+ for term in new_asset_depr_schedule_doc.get("depreciation_schedule"):
+ depreciation_amount = flt((term.depreciation_amount * remaining_qty) / asset.asset_quantity)
+ term.depreciation_amount = depreciation_amount
+ accumulated_depreciation += depreciation_amount
+ term.accumulated_depreciation_amount = accumulated_depreciation
+
+ notes = _(
+ "This schedule was created when Asset {0} was updated after being split into new Asset {1}."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name), get_link_to_form(asset.doctype, new_asset_name)
)
+ new_asset_depr_schedule_doc.notes = notes
+
+ current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True
+ current_asset_depr_schedule_doc.cancel()
+
+ new_asset_depr_schedule_doc.submit()
def create_new_asset_after_split(asset, split_qty):
@@ -1171,31 +973,49 @@ def create_new_asset_after_split(asset, split_qty):
new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation
new_asset.asset_quantity = split_qty
new_asset.split_from = asset.name
- accumulated_depreciation = 0
- for finance_book in new_asset.get("finance_books"):
- finance_book.value_after_depreciation = flt(
- (finance_book.value_after_depreciation * split_qty) / asset.asset_quantity
+ for row in new_asset.get("finance_books"):
+ row.value_after_depreciation = flt(
+ (row.value_after_depreciation * split_qty) / asset.asset_quantity
)
- finance_book.expected_value_after_useful_life = flt(
- (finance_book.expected_value_after_useful_life * split_qty) / asset.asset_quantity
+ row.expected_value_after_useful_life = flt(
+ (row.expected_value_after_useful_life * split_qty) / asset.asset_quantity
)
- for term in new_asset.get("schedules"):
- depreciation_amount = flt((term.depreciation_amount * split_qty) / asset.asset_quantity)
- term.depreciation_amount = depreciation_amount
- accumulated_depreciation += depreciation_amount
- term.accumulated_depreciation_amount = accumulated_depreciation
-
new_asset.submit()
new_asset.set_status()
- for term in new_asset.get("schedules"):
- # Update references in JV
- if term.journal_entry:
- add_reference_in_jv_on_split(
- term.journal_entry, new_asset.name, asset.name, term.depreciation_amount
- )
+ for row in new_asset.get("finance_books"):
+ current_asset_depr_schedule_doc = get_asset_depr_schedule_doc(
+ asset.name, "Active", row.finance_book
+ )
+ new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc)
+
+ new_asset_depr_schedule_doc.set_draft_asset_depr_schedule_details(new_asset, row)
+
+ accumulated_depreciation = 0
+
+ for term in new_asset_depr_schedule_doc.get("depreciation_schedule"):
+ depreciation_amount = flt((term.depreciation_amount * split_qty) / asset.asset_quantity)
+ term.depreciation_amount = depreciation_amount
+ accumulated_depreciation += depreciation_amount
+ term.accumulated_depreciation_amount = accumulated_depreciation
+
+ notes = _("This schedule was created when new Asset {0} was split from Asset {1}.").format(
+ get_link_to_form(new_asset.doctype, new_asset.name), get_link_to_form(asset.doctype, asset.name)
+ )
+ new_asset_depr_schedule_doc.notes = notes
+
+ new_asset_depr_schedule_doc.submit()
+
+ for row in new_asset.get("finance_books"):
+ depr_schedule = get_depr_schedule(new_asset.name, "Active", row.finance_book)
+ for term in depr_schedule:
+ # Update references in JV
+ if term.journal_entry:
+ add_reference_in_jv_on_split(
+ term.journal_entry, new_asset.name, asset.name, term.depreciation_amount
+ )
return new_asset
diff --git a/erpnext/assets/doctype/asset/asset_list.js b/erpnext/assets/doctype/asset/asset_list.js
index 4302cb2c51..3d00eb74aa 100644
--- a/erpnext/assets/doctype/asset/asset_list.js
+++ b/erpnext/assets/doctype/asset/asset_list.js
@@ -10,6 +10,9 @@ frappe.listview_settings['Asset'] = {
} else if (doc.status === "Sold") {
return [__("Sold"), "green", "status,=,Sold"];
+ } else if (["Capitalized", "Decapitalized"].includes(doc.status)) {
+ return [__(doc.status), "grey", "status,=," + doc.status];
+
} else if (doc.status === "Scrapped") {
return [__("Scrapped"), "grey", "status,=,Scrapped"];
diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py
index 3f7e945994..fb6e174fba 100644
--- a/erpnext/assets/doctype/asset/depreciation.py
+++ b/erpnext/assets/doctype/asset/depreciation.py
@@ -4,11 +4,29 @@
import frappe
from frappe import _
-from frappe.utils import cint, flt, getdate, today
+from frappe.utils import (
+ add_months,
+ cint,
+ flt,
+ get_last_day,
+ get_link_to_form,
+ getdate,
+ is_last_day_of_the_month,
+ nowdate,
+ today,
+)
+from frappe.utils.user import get_users_with_role
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
+from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+ get_asset_depr_schedule_name,
+ get_temp_asset_depr_schedule_doc,
+ make_new_active_asset_depr_schedules_and_cancel_current_ones,
+)
def post_depreciation_entries(date=None):
@@ -20,29 +38,58 @@ def post_depreciation_entries(date=None):
if not date:
date = today()
- for asset in get_depreciable_assets(date):
- make_depreciation_entry(asset, date)
- frappe.db.commit()
+
+ failed_asset_names = []
+
+ for asset_name in get_depreciable_assets(date):
+ asset_doc = frappe.get_doc("Asset", asset_name)
+
+ try:
+ make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date)
+ frappe.db.commit()
+ except Exception as e:
+ frappe.db.rollback()
+ failed_asset_names.append(asset_name)
+
+ if failed_asset_names:
+ set_depr_entry_posting_status_for_failed_assets(failed_asset_names)
+ notify_depr_entry_posting_error(failed_asset_names)
+
+ frappe.db.commit()
def get_depreciable_assets(date):
return frappe.db.sql_list(
"""select distinct a.name
- from tabAsset a, `tabDepreciation Schedule` ds
- where a.name = ds.parent and a.docstatus=1 and ds.schedule_date<=%s and a.calculate_depreciation = 1
+ from tabAsset a, `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds
+ where a.name = ads.asset and ads.name = ds.parent and a.docstatus=1 and ads.docstatus=1
and a.status in ('Submitted', 'Partially Depreciated')
+ and a.calculate_depreciation = 1
+ and ds.schedule_date<=%s
and ifnull(ds.journal_entry, '')=''""",
date,
)
+def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None):
+ for row in asset_doc.get("finance_books"):
+ asset_depr_schedule_name = get_asset_depr_schedule_name(
+ asset_doc.name, "Active", row.finance_book
+ )
+ make_depreciation_entry(asset_depr_schedule_name, date)
+
+
@frappe.whitelist()
-def make_depreciation_entry(asset_name, date=None):
+def make_depreciation_entry(asset_depr_schedule_name, date=None):
frappe.has_permission("Journal Entry", throw=True)
if not date:
date = today()
+ asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
+
+ asset_name = asset_depr_schedule_doc.asset
+
asset = frappe.get_doc("Asset", asset_name)
(
fixed_asset_account,
@@ -58,14 +105,14 @@ def make_depreciation_entry(asset_name, date=None):
accounting_dimensions = get_checks_for_pl_and_bs_accounts()
- for d in asset.get("schedules"):
+ for d in asset_depr_schedule_doc.get("depreciation_schedule"):
if not d.journal_entry and getdate(d.schedule_date) <= getdate(date):
je = frappe.new_doc("Journal Entry")
je.voucher_type = "Depreciation Entry"
je.naming_series = depreciation_series
je.posting_date = d.schedule_date
je.company = asset.company
- je.finance_book = d.finance_book
+ je.finance_book = asset_depr_schedule_doc.finance_book
je.remark = "Depreciation Entry against {0} worth {1}".format(asset_name, d.depreciation_amount)
credit_account, debit_account = get_credit_and_debit_accounts(
@@ -116,14 +163,16 @@ def make_depreciation_entry(asset_name, date=None):
d.db_set("journal_entry", je.name)
- idx = cint(d.finance_book_id)
- finance_books = asset.get("finance_books")[idx - 1]
- finance_books.value_after_depreciation -= d.depreciation_amount
- finance_books.db_update()
+ idx = cint(asset_depr_schedule_doc.finance_book_id)
+ row = asset.get("finance_books")[idx - 1]
+ row.value_after_depreciation -= d.depreciation_amount
+ row.db_update()
+
+ asset.db_set("depr_entry_posting_status", "Successful")
asset.set_status()
- return asset
+ return asset_depr_schedule_doc
def get_depreciation_accounts(asset):
@@ -184,17 +233,62 @@ def get_credit_and_debit_accounts(accumulated_depreciation_account, depreciation
return credit_account, debit_account
+def set_depr_entry_posting_status_for_failed_assets(failed_asset_names):
+ for asset_name in failed_asset_names:
+ frappe.db.set_value("Asset", asset_name, "depr_entry_posting_status", "Failed")
+
+
+def notify_depr_entry_posting_error(failed_asset_names):
+ recipients = get_users_with_role("Accounts Manager")
+
+ if not recipients:
+ recipients = get_users_with_role("System Manager")
+
+ subject = _("Error while posting depreciation entries")
+
+ asset_links = get_comma_separated_asset_links(failed_asset_names)
+
+ message = (
+ _("Hi,")
+ + " "
+ + _("The following assets have failed to post depreciation entries: {0}").format(asset_links)
+ + "."
+ )
+
+ frappe.sendmail(recipients=recipients, subject=subject, message=message)
+
+
+def get_comma_separated_asset_links(asset_names):
+ asset_links = []
+
+ for asset_name in asset_names:
+ asset_links.append(get_link_to_form("Asset", asset_name))
+
+ asset_links = ", ".join(asset_links)
+
+ return asset_links
+
+
@frappe.whitelist()
def scrap_asset(asset_name):
asset = frappe.get_doc("Asset", asset_name)
if asset.docstatus != 1:
frappe.throw(_("Asset {0} must be submitted").format(asset.name))
- elif asset.status in ("Cancelled", "Sold", "Scrapped"):
+ elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized", "Decapitalized"):
frappe.throw(
_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status)
)
+ date = today()
+
+ notes = _("This schedule was created when Asset {0} was scrapped.").format(
+ get_link_to_form(asset.doctype, asset.name)
+ )
+
+ depreciate_asset(asset, date, notes)
+ asset.reload()
+
depreciation_series = frappe.get_cached_value(
"Company", asset.company, "series_for_depreciation_entry"
)
@@ -202,7 +296,7 @@ def scrap_asset(asset_name):
je = frappe.new_doc("Journal Entry")
je.voucher_type = "Journal Entry"
je.naming_series = depreciation_series
- je.posting_date = today()
+ je.posting_date = date
je.company = asset.company
je.remark = "Scrap Entry for asset {0}".format(asset_name)
@@ -213,7 +307,7 @@ def scrap_asset(asset_name):
je.flags.ignore_permissions = True
je.submit()
- frappe.db.set_value("Asset", asset_name, "disposal_date", today())
+ frappe.db.set_value("Asset", asset_name, "disposal_date", date)
frappe.db.set_value("Asset", asset_name, "journal_entry_for_scrap", je.name)
asset.set_status("Scrapped")
@@ -224,8 +318,16 @@ def scrap_asset(asset_name):
def restore_asset(asset_name):
asset = frappe.get_doc("Asset", asset_name)
+ reverse_depreciation_entry_made_after_disposal(asset, asset.disposal_date)
+
je = asset.journal_entry_for_scrap
+ notes = _("This schedule was created when Asset {0} was restored.").format(
+ get_link_to_form(asset.doctype, asset.name)
+ )
+
+ reset_depreciation_schedule(asset, asset.disposal_date, notes)
+
asset.db_set("disposal_date", None)
asset.db_set("journal_entry_for_scrap", None)
@@ -234,7 +336,99 @@ def restore_asset(asset_name):
asset.set_status()
-def get_gl_entries_on_asset_regain(asset, selling_amount=0, finance_book=None):
+def depreciate_asset(asset_doc, date, notes):
+ asset_doc.flags.ignore_validate_update_after_submit = True
+
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(
+ asset_doc, notes, date_of_disposal=date
+ )
+
+ asset_doc.save()
+
+ make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date)
+
+
+def reset_depreciation_schedule(asset_doc, date, notes):
+ asset_doc.flags.ignore_validate_update_after_submit = True
+
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(
+ asset_doc, notes, date_of_return=date
+ )
+
+ modify_depreciation_schedule_for_asset_repairs(asset_doc, notes)
+
+ asset_doc.save()
+
+
+def modify_depreciation_schedule_for_asset_repairs(asset, notes):
+ asset_repairs = frappe.get_all(
+ "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"]
+ )
+
+ for repair in asset_repairs:
+ if repair.increase_in_asset_life:
+ asset_repair = frappe.get_doc("Asset Repair", repair.name)
+ asset_repair.modify_depreciation_schedule()
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes)
+
+
+def reverse_depreciation_entry_made_after_disposal(asset, date):
+ for row in asset.get("finance_books"):
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset.name, "Active", row.finance_book)
+
+ for schedule_idx, schedule in enumerate(asset_depr_schedule_doc.get("depreciation_schedule")):
+ if schedule.schedule_date == date:
+ if not disposal_was_made_on_original_schedule_date(
+ schedule_idx, row, date
+ ) or disposal_happens_in_the_future(date):
+
+ reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry)
+ reverse_journal_entry.posting_date = nowdate()
+ frappe.flags.is_reverse_depr_entry = True
+ reverse_journal_entry.submit()
+
+ frappe.flags.is_reverse_depr_entry = False
+ asset_depr_schedule_doc.flags.ignore_validate_update_after_submit = True
+ asset.flags.ignore_validate_update_after_submit = True
+ schedule.journal_entry = None
+ depreciation_amount = get_depreciation_amount_in_je(reverse_journal_entry)
+ row.value_after_depreciation += depreciation_amount
+ asset_depr_schedule_doc.save()
+ asset.save()
+
+
+def get_depreciation_amount_in_je(journal_entry):
+ if journal_entry.accounts[0].debit_in_account_currency:
+ return journal_entry.accounts[0].debit_in_account_currency
+ else:
+ return journal_entry.accounts[0].credit_in_account_currency
+
+
+# if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone
+def disposal_was_made_on_original_schedule_date(schedule_idx, row, posting_date_of_disposal):
+ orginal_schedule_date = add_months(
+ row.depreciation_start_date, schedule_idx * cint(row.frequency_of_depreciation)
+ )
+
+ if is_last_day_of_the_month(row.depreciation_start_date):
+ orginal_schedule_date = get_last_day(orginal_schedule_date)
+
+ if orginal_schedule_date == posting_date_of_disposal:
+ return True
+
+ return False
+
+
+def disposal_happens_in_the_future(posting_date_of_disposal):
+ if posting_date_of_disposal > getdate():
+ return True
+
+ return False
+
+
+def get_gl_entries_on_asset_regain(
+ asset, selling_amount=0, finance_book=None, voucher_type=None, voucher_no=None
+):
(
fixed_asset_account,
asset,
@@ -246,28 +440,45 @@ def get_gl_entries_on_asset_regain(asset, selling_amount=0, finance_book=None):
) = get_asset_details(asset, finance_book)
gl_entries = [
- {
- "account": fixed_asset_account,
- "debit_in_account_currency": asset.gross_purchase_amount,
- "debit": asset.gross_purchase_amount,
- "cost_center": depreciation_cost_center,
- },
- {
- "account": accumulated_depr_account,
- "credit_in_account_currency": accumulated_depr_amount,
- "credit": accumulated_depr_amount,
- "cost_center": depreciation_cost_center,
- },
+ asset.get_gl_dict(
+ {
+ "account": fixed_asset_account,
+ "debit_in_account_currency": asset.gross_purchase_amount,
+ "debit": asset.gross_purchase_amount,
+ "cost_center": depreciation_cost_center,
+ "posting_date": getdate(),
+ },
+ item=asset,
+ ),
+ asset.get_gl_dict(
+ {
+ "account": accumulated_depr_account,
+ "credit_in_account_currency": accumulated_depr_amount,
+ "credit": accumulated_depr_amount,
+ "cost_center": depreciation_cost_center,
+ "posting_date": getdate(),
+ },
+ item=asset,
+ ),
]
profit_amount = abs(flt(value_after_depreciation)) - abs(flt(selling_amount))
if profit_amount:
- get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center)
+ get_profit_gl_entries(
+ asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center
+ )
+
+ if voucher_type and voucher_no:
+ for entry in gl_entries:
+ entry["voucher_type"] = voucher_type
+ entry["voucher_no"] = voucher_no
return gl_entries
-def get_gl_entries_on_asset_disposal(asset, selling_amount=0, finance_book=None):
+def get_gl_entries_on_asset_disposal(
+ asset, selling_amount=0, finance_book=None, voucher_type=None, voucher_no=None
+):
(
fixed_asset_account,
asset,
@@ -279,23 +490,38 @@ def get_gl_entries_on_asset_disposal(asset, selling_amount=0, finance_book=None)
) = get_asset_details(asset, finance_book)
gl_entries = [
- {
- "account": fixed_asset_account,
- "credit_in_account_currency": asset.gross_purchase_amount,
- "credit": asset.gross_purchase_amount,
- "cost_center": depreciation_cost_center,
- },
- {
- "account": accumulated_depr_account,
- "debit_in_account_currency": accumulated_depr_amount,
- "debit": accumulated_depr_amount,
- "cost_center": depreciation_cost_center,
- },
+ asset.get_gl_dict(
+ {
+ "account": fixed_asset_account,
+ "credit_in_account_currency": asset.gross_purchase_amount,
+ "credit": asset.gross_purchase_amount,
+ "cost_center": depreciation_cost_center,
+ "posting_date": getdate(),
+ },
+ item=asset,
+ ),
+ asset.get_gl_dict(
+ {
+ "account": accumulated_depr_account,
+ "debit_in_account_currency": accumulated_depr_amount,
+ "debit": accumulated_depr_amount,
+ "cost_center": depreciation_cost_center,
+ "posting_date": getdate(),
+ },
+ item=asset,
+ ),
]
profit_amount = flt(selling_amount) - flt(value_after_depreciation)
if profit_amount:
- get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center)
+ get_profit_gl_entries(
+ asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center
+ )
+
+ if voucher_type and voucher_no:
+ for entry in gl_entries:
+ entry["voucher_type"] = voucher_type
+ entry["voucher_no"] = voucher_no
return gl_entries
@@ -307,18 +533,8 @@ def get_asset_details(asset, finance_book=None):
disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company)
depreciation_cost_center = asset.cost_center or depreciation_cost_center
- idx = 1
- if finance_book:
- for d in asset.finance_books:
- if d.finance_book == finance_book:
- idx = d.idx
- break
+ value_after_depreciation = asset.get_value_after_depreciation(finance_book)
- value_after_depreciation = (
- asset.finance_books[idx - 1].value_after_depreciation
- if asset.calculate_depreciation
- else asset.value_after_depreciation
- )
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
return (
@@ -332,15 +548,21 @@ def get_asset_details(asset, finance_book=None):
)
-def get_profit_gl_entries(profit_amount, gl_entries, disposal_account, depreciation_cost_center):
+def get_profit_gl_entries(
+ asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center
+):
debit_or_credit = "debit" if profit_amount < 0 else "credit"
gl_entries.append(
- {
- "account": disposal_account,
- "cost_center": depreciation_cost_center,
- debit_or_credit: abs(profit_amount),
- debit_or_credit + "_in_account_currency": abs(profit_amount),
- }
+ asset.get_gl_dict(
+ {
+ "account": disposal_account,
+ "cost_center": depreciation_cost_center,
+ debit_or_credit: abs(profit_amount),
+ debit_or_credit + "_in_account_currency": abs(profit_amount),
+ "posting_date": getdate(),
+ },
+ item=asset,
+ )
)
@@ -358,3 +580,33 @@ def get_disposal_account_and_cost_center(company):
frappe.throw(_("Please set 'Asset Depreciation Cost Center' in Company {0}").format(company))
return disposal_account, depreciation_cost_center
+
+
+@frappe.whitelist()
+def get_value_after_depreciation_on_disposal_date(asset, disposal_date, finance_book=None):
+ asset_doc = frappe.get_doc("Asset", asset)
+
+ if not asset_doc.calculate_depreciation:
+ return flt(asset_doc.value_after_depreciation)
+
+ idx = 1
+ if finance_book:
+ for d in asset.finance_books:
+ if d.finance_book == finance_book:
+ idx = d.idx
+ break
+
+ row = asset_doc.finance_books[idx - 1]
+
+ temp_asset_depreciation_schedule = get_temp_asset_depr_schedule_doc(
+ asset_doc, row, getdate(disposal_date)
+ )
+
+ accumulated_depr_amount = temp_asset_depreciation_schedule.get("depreciation_schedule")[
+ -1
+ ].accumulated_depreciation_amount
+
+ return flt(
+ flt(asset_doc.gross_purchase_amount) - accumulated_depr_amount,
+ asset_doc.precision("gross_purchase_amount"),
+ )
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 986b7001ff..9a152638f9 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -4,15 +4,34 @@
import unittest
import frappe
-from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ cstr,
+ flt,
+ get_first_day,
+ get_last_day,
+ getdate,
+ is_last_day_of_the_month,
+ nowdate,
+)
+from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
-from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset
+from erpnext.assets.doctype.asset.asset import (
+ make_sales_invoice,
+ split_asset,
+ update_maintenance_status,
+)
from erpnext.assets.doctype.asset.depreciation import (
post_depreciation_entries,
restore_asset,
scrap_asset,
)
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+ get_depr_schedule,
+)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_invoice,
)
@@ -178,28 +197,67 @@ class TestAsset(AssetSetup):
self.assertEqual(doc.items[0].is_fixed_asset, 1)
def test_scrap_asset(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+
asset = create_asset(
calculate_depreciation=1,
- available_for_use_date="2020-01-01",
- purchase_date="2020-01-01",
+ available_for_use_date=purchase_date,
+ purchase_date=purchase_date,
expected_value_after_useful_life=10000,
total_number_of_depreciations=10,
frequency_of_depreciation=1,
submit=1,
)
- post_depreciation_entries(date=add_months("2020-01-01", 4))
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
+ post_depreciation_entries(date=add_months(purchase_date, 2))
+ asset.load_from_db()
+
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ self.assertEquals(accumulated_depr_amount, 18000.0)
scrap_asset(asset.name)
-
asset.load_from_db()
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
+
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ pro_rata_amount, _, _ = asset.get_pro_rata_amt(
+ asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
+ )
+ pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
+ self.assertEquals(
+ accumulated_depr_amount,
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ )
+
self.assertEqual(asset.status, "Scrapped")
self.assertTrue(asset.journal_entry_for_scrap)
expected_gle = (
- ("_Test Accumulated Depreciations - _TC", 36000.0, 0.0),
+ (
+ "_Test Accumulated Depreciations - _TC",
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
("_Test Fixed Asset - _TC", 0.0, 100000.0),
- ("_Test Gain/Loss on Asset Disposal - _TC", 64000.0, 0.0),
+ (
+ "_Test Gain/Loss on Asset Disposal - _TC",
+ flt(82000.0 - pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
)
gle = frappe.db.sql(
@@ -211,12 +269,91 @@ class TestAsset(AssetSetup):
self.assertSequenceEqual(gle, expected_gle)
restore_asset(asset.name)
+ second_asset_depr_schedule.load_from_db()
+
+ third_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(third_asset_depr_schedule.status, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Cancelled")
asset.load_from_db()
self.assertFalse(asset.journal_entry_for_scrap)
self.assertEqual(asset.status, "Partially Depreciated")
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ this_month_depr_amount = 9000.0 if is_last_day_of_the_month(date) else 0
+
+ self.assertEquals(accumulated_depr_amount, 18000.0 + this_month_depr_amount)
+
def test_gle_made_by_asset_sale(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+
+ asset = create_asset(
+ calculate_depreciation=1,
+ available_for_use_date=purchase_date,
+ purchase_date=purchase_date,
+ expected_value_after_useful_life=10000,
+ total_number_of_depreciations=10,
+ frequency_of_depreciation=1,
+ submit=1,
+ )
+
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
+ post_depreciation_entries(date=add_months(purchase_date, 2))
+
+ si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
+ si.customer = "_Test Customer"
+ si.due_date = nowdate()
+ si.get("items")[0].rate = 25000
+ si.insert()
+ si.submit()
+
+ self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
+
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
+
+ pro_rata_amount, _, _ = asset.get_pro_rata_amt(
+ asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
+ )
+ pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
+
+ expected_gle = (
+ (
+ "_Test Accumulated Depreciations - _TC",
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
+ ("_Test Fixed Asset - _TC", 0.0, 100000.0),
+ (
+ "_Test Gain/Loss on Asset Disposal - _TC",
+ flt(57000.0 - pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
+ ("Debtors - _TC", 25000.0, 0.0),
+ )
+
+ gle = frappe.db.sql(
+ """select account, debit, credit from `tabGL Entry`
+ where voucher_type='Sales Invoice' and voucher_no = %s
+ order by account""",
+ si.name,
+ )
+
+ self.assertSequenceEqual(gle, expected_gle)
+
+ si.cancel()
+ self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
+
+ def test_asset_with_maintenance_required_status_after_sale(self):
asset = create_asset(
calculate_depreciation=1,
available_for_use_date="2020-06-06",
@@ -224,6 +361,7 @@ class TestAsset(AssetSetup):
expected_value_after_useful_life=10000,
total_number_of_depreciations=3,
frequency_of_depreciation=10,
+ maintenance_required=1,
depreciation_start_date="2020-12-31",
submit=1,
)
@@ -239,24 +377,9 @@ class TestAsset(AssetSetup):
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
- expected_gle = (
- ("_Test Accumulated Depreciations - _TC", 20490.2, 0.0),
- ("_Test Fixed Asset - _TC", 0.0, 100000.0),
- ("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0),
- ("Debtors - _TC", 25000.0, 0.0),
- )
+ update_maintenance_status()
- gle = frappe.db.sql(
- """select account, debit, credit from `tabGL Entry`
- where voucher_type='Sales Invoice' and voucher_no = %s
- order by account""",
- si.name,
- )
-
- self.assertSequenceEqual(gle, expected_gle)
-
- si.cancel()
- self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
+ self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
def test_asset_splitting(self):
asset = create_asset(
@@ -274,6 +397,9 @@ class TestAsset(AssetSetup):
submit=1,
)
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
post_depreciation_entries(date="2021-01-01")
self.assertEqual(asset.asset_quantity, 10)
@@ -282,21 +408,31 @@ class TestAsset(AssetSetup):
new_asset = split_asset(asset.name, 2)
asset.load_from_db()
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ first_asset_depr_schedule_of_new_asset = get_asset_depr_schedule_doc(new_asset.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule_of_new_asset.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
+
+ depr_schedule_of_asset = second_asset_depr_schedule.get("depreciation_schedule")
+ depr_schedule_of_new_asset = first_asset_depr_schedule_of_new_asset.get("depreciation_schedule")
self.assertEqual(new_asset.asset_quantity, 2)
self.assertEqual(new_asset.gross_purchase_amount, 24000)
self.assertEqual(new_asset.opening_accumulated_depreciation, 4000)
self.assertEqual(new_asset.split_from, asset.name)
- self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000)
- self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000)
+ self.assertEqual(depr_schedule_of_new_asset[0].depreciation_amount, 4000)
+ self.assertEqual(depr_schedule_of_new_asset[1].depreciation_amount, 4000)
self.assertEqual(asset.asset_quantity, 8)
self.assertEqual(asset.gross_purchase_amount, 96000)
self.assertEqual(asset.opening_accumulated_depreciation, 16000)
- self.assertEqual(asset.schedules[0].depreciation_amount, 16000)
- self.assertEqual(asset.schedules[1].depreciation_amount, 16000)
+ self.assertEqual(depr_schedule_of_asset[0].depreciation_amount, 16000)
+ self.assertEqual(depr_schedule_of_asset[1].depreciation_amount, 16000)
- journal_entry = asset.schedules[0].journal_entry
+ journal_entry = depr_schedule_of_asset[0].journal_entry
jv = frappe.get_doc("Journal Entry", journal_entry)
self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000)
@@ -533,7 +669,7 @@ class TestDepreciationMethods(AssetSetup):
schedules = [
[cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -555,7 +691,7 @@ class TestDepreciationMethods(AssetSetup):
expected_schedules = [["2032-12-31", 30000.0, 77095.89], ["2033-06-06", 12904.11, 90000.0]]
schedules = [
[cstr(d.schedule_date), flt(d.depreciation_amount, 2), d.accumulated_depreciation_amount]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -582,7 +718,7 @@ class TestDepreciationMethods(AssetSetup):
schedules = [
[cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -607,7 +743,7 @@ class TestDepreciationMethods(AssetSetup):
schedules = [
[cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -637,7 +773,7 @@ class TestDepreciationMethods(AssetSetup):
flt(d.depreciation_amount, 2),
flt(d.accumulated_depreciation_amount, 2),
]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -669,7 +805,7 @@ class TestDepreciationMethods(AssetSetup):
flt(d.depreciation_amount, 2),
flt(d.accumulated_depreciation_amount, 2),
]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -702,7 +838,7 @@ class TestDepreciationMethods(AssetSetup):
flt(d.depreciation_amount, 2),
flt(d.accumulated_depreciation_amount, 2),
]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -735,7 +871,7 @@ class TestDepreciationMethods(AssetSetup):
flt(d.depreciation_amount, 2),
flt(d.accumulated_depreciation_amount, 2),
]
- for d in asset.get("schedules")
+ for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
@@ -758,7 +894,7 @@ class TestDepreciationBasics(AssetSetup):
["2022-12-31", 30000, 90000],
]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
@@ -781,16 +917,13 @@ class TestDepreciationBasics(AssetSetup):
["2023-01-01", 15000, 90000],
]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
def test_get_depreciation_amount(self):
"""Tests if get_depreciation_amount() returns the right value."""
-
- from erpnext.assets.doctype.asset.asset import get_depreciation_amount
-
asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31")
asset.calculate_depreciation = 1
@@ -805,11 +938,11 @@ class TestDepreciationBasics(AssetSetup):
},
)
- depreciation_amount = get_depreciation_amount(asset, 100000, asset.finance_books[0])
+ depreciation_amount = asset.get_depreciation_amount(100000, asset.finance_books[0])
self.assertEqual(depreciation_amount, 30000)
- def test_make_depreciation_schedule(self):
- """Tests if make_depreciation_schedule() returns the right values."""
+ def test_make_depr_schedule(self):
+ """Tests if make_depr_schedule() returns the right values."""
asset = create_asset(
item_code="Macbook Pro",
@@ -824,7 +957,7 @@ class TestDepreciationBasics(AssetSetup):
expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Draft")):
self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
@@ -844,7 +977,7 @@ class TestDepreciationBasics(AssetSetup):
expected_values = [30000.0, 60000.0, 90000.0]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Draft")):
self.assertEqual(expected_values[i], schedule.accumulated_depreciation_amount)
def test_check_is_pro_rata(self):
@@ -1024,9 +1157,11 @@ class TestDepreciationBasics(AssetSetup):
post_depreciation_entries(date="2021-06-01")
asset.load_from_db()
- self.assertTrue(asset.schedules[0].journal_entry)
- self.assertFalse(asset.schedules[1].journal_entry)
- self.assertFalse(asset.schedules[2].journal_entry)
+ depr_schedule = get_depr_schedule(asset.name, "Active")
+
+ self.assertTrue(depr_schedule[0].journal_entry)
+ self.assertFalse(depr_schedule[1].journal_entry)
+ self.assertFalse(depr_schedule[2].journal_entry)
def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self):
"""Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account."""
@@ -1045,7 +1180,7 @@ class TestDepreciationBasics(AssetSetup):
post_depreciation_entries(date="2021-06-01")
asset.load_from_db()
- je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry)
+ je = frappe.get_doc("Journal Entry", get_depr_schedule(asset.name, "Active")[0].journal_entry)
accounting_entries = [
{"account": entry.account, "debit": entry.debit, "credit": entry.credit}
for entry in je.accounts
@@ -1081,7 +1216,7 @@ class TestDepreciationBasics(AssetSetup):
post_depreciation_entries(date="2021-06-01")
asset.load_from_db()
- je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry)
+ je = frappe.get_doc("Journal Entry", get_depr_schedule(asset.name, "Active")[0].journal_entry)
accounting_entries = [
{"account": entry.account, "debit": entry.debit, "credit": entry.credit}
for entry in je.accounts
@@ -1100,8 +1235,8 @@ class TestDepreciationBasics(AssetSetup):
depr_expense_account.parent_account = "Expenses - _TC"
depr_expense_account.save()
- def test_clear_depreciation_schedule(self):
- """Tests if clear_depreciation_schedule() works as expected."""
+ def test_clear_depr_schedule(self):
+ """Tests if clear_depr_schedule() works as expected."""
asset = create_asset(
item_code="Macbook Pro",
@@ -1117,17 +1252,20 @@ class TestDepreciationBasics(AssetSetup):
post_depreciation_entries(date="2021-06-01")
asset.load_from_db()
- asset.clear_depreciation_schedule()
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset.name, "Active")
- self.assertEqual(len(asset.schedules), 1)
+ asset_depr_schedule_doc.clear_depr_schedule()
- def test_clear_depreciation_schedule_for_multiple_finance_books(self):
+ self.assertEqual(len(asset_depr_schedule_doc.get("depreciation_schedule")), 1)
+
+ def test_clear_depr_schedule_for_multiple_finance_books(self):
asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31", do_not_save=1)
asset.calculate_depreciation = 1
asset.append(
"finance_books",
{
+ "finance_book": "Test Finance Book 1",
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 1,
"total_number_of_depreciations": 3,
@@ -1138,6 +1276,7 @@ class TestDepreciationBasics(AssetSetup):
asset.append(
"finance_books",
{
+ "finance_book": "Test Finance Book 2",
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 1,
"total_number_of_depreciations": 6,
@@ -1148,6 +1287,7 @@ class TestDepreciationBasics(AssetSetup):
asset.append(
"finance_books",
{
+ "finance_book": "Test Finance Book 3",
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 3,
@@ -1160,15 +1300,23 @@ class TestDepreciationBasics(AssetSetup):
post_depreciation_entries(date="2020-04-01")
asset.load_from_db()
- asset.clear_depreciation_schedule()
+ asset_depr_schedule_doc_1 = get_asset_depr_schedule_doc(
+ asset.name, "Active", "Test Finance Book 1"
+ )
+ asset_depr_schedule_doc_1.clear_depr_schedule()
+ self.assertEqual(len(asset_depr_schedule_doc_1.get("depreciation_schedule")), 3)
- self.assertEqual(len(asset.schedules), 6)
+ asset_depr_schedule_doc_2 = get_asset_depr_schedule_doc(
+ asset.name, "Active", "Test Finance Book 2"
+ )
+ asset_depr_schedule_doc_2.clear_depr_schedule()
+ self.assertEqual(len(asset_depr_schedule_doc_2.get("depreciation_schedule")), 3)
- for schedule in asset.schedules:
- if schedule.idx <= 3:
- self.assertEqual(schedule.finance_book_id, "1")
- else:
- self.assertEqual(schedule.finance_book_id, "2")
+ asset_depr_schedule_doc_3 = get_asset_depr_schedule_doc(
+ asset.name, "Active", "Test Finance Book 3"
+ )
+ asset_depr_schedule_doc_3.clear_depr_schedule()
+ self.assertEqual(len(asset_depr_schedule_doc_3.get("depreciation_schedule")), 0)
def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self):
asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31", do_not_save=1)
@@ -1177,6 +1325,7 @@ class TestDepreciationBasics(AssetSetup):
asset.append(
"finance_books",
{
+ "finance_book": "Test Finance Book 1",
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 3,
@@ -1187,6 +1336,7 @@ class TestDepreciationBasics(AssetSetup):
asset.append(
"finance_books",
{
+ "finance_book": "Test Finance Book 2",
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 6,
@@ -1196,13 +1346,15 @@ class TestDepreciationBasics(AssetSetup):
)
asset.save()
- self.assertEqual(len(asset.schedules), 9)
+ asset_depr_schedule_doc_1 = get_asset_depr_schedule_doc(
+ asset.name, "Draft", "Test Finance Book 1"
+ )
+ self.assertEqual(len(asset_depr_schedule_doc_1.get("depreciation_schedule")), 3)
- for schedule in asset.schedules:
- if schedule.idx <= 3:
- self.assertEqual(schedule.finance_book_id, 1)
- else:
- self.assertEqual(schedule.finance_book_id, 2)
+ asset_depr_schedule_doc_2 = get_asset_depr_schedule_doc(
+ asset.name, "Draft", "Test Finance Book 2"
+ )
+ self.assertEqual(len(asset_depr_schedule_doc_2.get("depreciation_schedule")), 6)
def test_depreciation_entry_cancellation(self):
asset = create_asset(
@@ -1222,12 +1374,12 @@ class TestDepreciationBasics(AssetSetup):
asset.load_from_db()
# cancel depreciation entry
- depr_entry = asset.get("schedules")[0].journal_entry
+ depr_entry = get_depr_schedule(asset.name, "Active")[0].journal_entry
self.assertTrue(depr_entry)
+
frappe.get_doc("Journal Entry", depr_entry).cancel()
- asset.load_from_db()
- depr_entry = asset.get("schedules")[0].journal_entry
+ depr_entry = get_depr_schedule(asset.name, "Active")[0].journal_entry
self.assertFalse(depr_entry)
def test_asset_expected_value_after_useful_life(self):
@@ -1242,7 +1394,7 @@ class TestDepreciationBasics(AssetSetup):
)
accumulated_depreciation_after_full_schedule = max(
- d.accumulated_depreciation_amount for d in asset.get("schedules")
+ d.accumulated_depreciation_amount for d in get_depr_schedule(asset.name, "Draft")
)
asset_value_after_full_schedule = flt(asset.gross_purchase_amount) - flt(
@@ -1273,7 +1425,7 @@ class TestDepreciationBasics(AssetSetup):
asset.load_from_db()
# check depreciation entry series
- self.assertEqual(asset.get("schedules")[0].journal_entry[:4], "DEPR")
+ self.assertEqual(get_depr_schedule(asset.name, "Active")[0].journal_entry[:4], "DEPR")
expected_gle = (
("_Test Accumulated Depreciations - _TC", 0.0, 30000.0),
@@ -1343,9 +1495,39 @@ class TestDepreciationBasics(AssetSetup):
"2020-07-15",
]
- for i, schedule in enumerate(asset.schedules):
+ for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")):
self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date))
+ def test_manual_depreciation_for_existing_asset(self):
+ asset = create_asset(
+ item_code="Macbook Pro",
+ is_existing_asset=1,
+ purchase_date="2020-01-30",
+ available_for_use_date="2020-01-30",
+ submit=1,
+ )
+
+ self.assertEqual(asset.status, "Submitted")
+ self.assertEqual(asset.get("value_after_depreciation"), 100000)
+
+ jv = make_journal_entry(
+ "_Test Depreciations - _TC", "_Test Accumulated Depreciations - _TC", 100, save=False
+ )
+ for d in jv.accounts:
+ d.reference_type = "Asset"
+ d.reference_name = asset.name
+ jv.voucher_type = "Depreciation Entry"
+ jv.insert()
+ jv.submit()
+
+ asset.reload()
+ self.assertEqual(asset.get("value_after_depreciation"), 99900)
+
+ jv.cancel()
+
+ asset.reload()
+ self.assertEqual(asset.get("value_after_depreciation"), 100000)
+
def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"):
@@ -1357,6 +1539,15 @@ def create_asset_data():
if not frappe.db.exists("Location", "Test Location"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location"}).insert()
+ if not frappe.db.exists("Finance Book", "Test Finance Book 1"):
+ frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 1"}).insert()
+
+ if not frappe.db.exists("Finance Book", "Test Finance Book 2"):
+ frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 2"}).insert()
+
+ if not frappe.db.exists("Finance Book", "Test Finance Book 3"):
+ frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 3"}).insert()
+
def create_asset(**args):
args = frappe._dict(args)
@@ -1376,12 +1567,14 @@ def create_asset(**args):
"number_of_depreciations_booked": args.number_of_depreciations_booked or 0,
"gross_purchase_amount": args.gross_purchase_amount or 100000,
"purchase_receipt_amount": args.purchase_receipt_amount or 100000,
+ "maintenance_required": args.maintenance_required or 0,
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location",
"asset_owner": args.asset_owner or "Company",
"is_existing_asset": args.is_existing_asset or 1,
"asset_quantity": args.get("asset_quantity") or 1,
+ "depr_entry_posting_status": args.depr_entry_posting_status or "",
}
)
@@ -1425,6 +1618,16 @@ def create_asset_category():
"depreciation_expense_account": "_Test Depreciations - _TC",
},
)
+ asset_category.append(
+ "accounts",
+ {
+ "company_name": "_Test Company with perpetual inventory",
+ "fixed_asset_account": "_Test Fixed Asset - TCP1",
+ "accumulated_depreciation_account": "_Test Accumulated Depreciations - TCP1",
+ "depreciation_expense_account": "_Test Depreciations - TCP1",
+ },
+ )
+
asset_category.insert()
@@ -1454,12 +1657,14 @@ def create_fixed_asset_item(item_code=None, auto_create_assets=1, is_grouped_ass
return item
-def set_depreciation_settings_in_company():
- company = frappe.get_doc("Company", "_Test Company")
- company.accumulated_depreciation_account = "_Test Accumulated Depreciations - _TC"
- company.depreciation_expense_account = "_Test Depreciations - _TC"
- company.disposal_account = "_Test Gain/Loss on Asset Disposal - _TC"
- company.depreciation_cost_center = "_Test Cost Center - _TC"
+def set_depreciation_settings_in_company(company=None):
+ if not company:
+ company = "_Test Company"
+ company = frappe.get_doc("Company", company)
+ company.accumulated_depreciation_account = "_Test Accumulated Depreciations - " + company.abbr
+ company.depreciation_expense_account = "_Test Depreciations - " + company.abbr
+ company.disposal_account = "_Test Gain/Loss on Asset Disposal - " + company.abbr
+ company.depreciation_cost_center = "Main - " + company.abbr
company.save()
# Enable booking asset depreciation entry automatically
diff --git a/erpnext/regional/doctype/ksa_vat_setting/__init__.py b/erpnext/assets/doctype/asset_capitalization/__init__.py
similarity index 100%
rename from erpnext/regional/doctype/ksa_vat_setting/__init__.py
rename to erpnext/assets/doctype/asset_capitalization/__init__.py
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js
new file mode 100644
index 0000000000..9c7f70b0e5
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js
@@ -0,0 +1,417 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.provide("erpnext.assets");
+
+
+erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.stock.StockController {
+ setup() {
+ this.setup_posting_date_time_check();
+ }
+
+ onload() {
+ this.setup_queries();
+ }
+
+ refresh() {
+ erpnext.hide_company();
+ this.show_general_ledger();
+ if ((this.frm.doc.stock_items && this.frm.doc.stock_items.length) || !this.frm.doc.target_is_fixed_asset) {
+ this.show_stock_ledger();
+ }
+ }
+
+ setup_queries() {
+ var me = this;
+
+ me.setup_warehouse_query();
+
+ me.frm.set_query("target_item_code", function() {
+ if (me.frm.doc.entry_type == "Capitalization") {
+ return erpnext.queries.item({"is_stock_item": 0, "is_fixed_asset": 1});
+ } else {
+ return erpnext.queries.item({"is_stock_item": 1, "is_fixed_asset": 0});
+ }
+ });
+
+ me.frm.set_query("target_asset", function() {
+ var filters = {};
+
+ if (me.frm.doc.target_item_code) {
+ filters['item_code'] = me.frm.doc.target_item_code;
+ }
+
+ filters['status'] = ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]];
+ filters['docstatus'] = 1;
+
+ return {
+ filters: filters
+ };
+ });
+
+ me.frm.set_query("asset", "asset_items", function() {
+ var filters = {
+ 'status': ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]],
+ 'docstatus': 1
+ };
+
+ if (me.frm.doc.target_asset) {
+ filters['name'] = ['!=', me.frm.doc.target_asset];
+ }
+
+ return {
+ filters: filters
+ };
+ });
+
+ me.frm.set_query("item_code", "stock_items", function() {
+ return erpnext.queries.item({"is_stock_item": 1});
+ });
+
+ me.frm.set_query("item_code", "service_items", function() {
+ return erpnext.queries.item({"is_stock_item": 0, "is_fixed_asset": 0});
+ });
+
+ me.frm.set_query('batch_no', 'stock_items', function(doc, cdt, cdn) {
+ var item = locals[cdt][cdn];
+ if (!item.item_code) {
+ frappe.throw(__("Please enter Item Code to get Batch Number"));
+ } else {
+ var filters = {
+ 'item_code': item.item_code,
+ 'posting_date': me.frm.doc.posting_date || frappe.datetime.nowdate(),
+ 'warehouse': item.warehouse
+ };
+
+ return {
+ query: "erpnext.controllers.queries.get_batch_no",
+ filters: filters
+ };
+ }
+ });
+
+ me.frm.set_query('expense_account', 'service_items', function() {
+ return {
+ filters: {
+ "account_type": ['in', ["Tax", "Expense Account", "Income Account", "Expenses Included In Valuation", "Expenses Included In Asset Valuation"]],
+ "is_group": 0,
+ "company": me.frm.doc.company
+ }
+ };
+ });
+ }
+
+ target_item_code() {
+ return this.get_target_item_details();
+ }
+
+ target_asset() {
+ return this.get_target_asset_details();
+ }
+
+ item_code(doc, cdt, cdn) {
+ var row = frappe.get_doc(cdt, cdn);
+ if (cdt === "Asset Capitalization Stock Item") {
+ this.get_consumed_stock_item_details(row);
+ } else if (cdt == "Asset Capitalization Service Item") {
+ this.get_service_item_details(row);
+ }
+ }
+
+ warehouse(doc, cdt, cdn) {
+ var row = frappe.get_doc(cdt, cdn);
+ if (cdt === "Asset Capitalization Stock Item") {
+ this.get_warehouse_details(row);
+ }
+ }
+
+ asset(doc, cdt, cdn) {
+ var row = frappe.get_doc(cdt, cdn);
+ if (cdt === "Asset Capitalization Asset Item") {
+ this.get_consumed_asset_details(row);
+ }
+ }
+
+ posting_date() {
+ if (this.frm.doc.posting_date) {
+ frappe.run_serially([
+ () => this.get_all_item_warehouse_details(),
+ () => this.get_all_asset_values()
+ ]);
+ }
+ }
+
+ posting_time() {
+ if (this.frm.doc.posting_time) {
+ this.get_all_item_warehouse_details();
+ }
+ }
+
+ finance_book(doc, cdt, cdn) {
+ if (cdt === "Asset Capitalization Asset Item") {
+ var row = frappe.get_doc(cdt, cdn);
+ this.get_consumed_asset_details(row);
+ } else {
+ this.get_all_asset_values();
+ }
+ }
+
+ stock_qty() {
+ this.calculate_totals();
+ }
+
+ qty() {
+ this.calculate_totals();
+ }
+
+ target_qty() {
+ this.calculate_totals();
+ }
+
+ rate() {
+ this.calculate_totals();
+ }
+
+ company() {
+ var me = this;
+
+ if (me.frm.doc.company) {
+ frappe.model.set_value(me.frm.doc.doctype, me.frm.doc.name, "cost_center", null);
+ $.each(me.frm.doc.stock_items || [], function (i, d) {
+ frappe.model.set_value(d.doctype, d.name, "cost_center", null);
+ });
+ $.each(me.frm.doc.asset_items || [], function (i, d) {
+ frappe.model.set_value(d.doctype, d.name, "cost_center", null);
+ });
+ $.each(me.frm.doc.service_items || [], function (i, d) {
+ frappe.model.set_value(d.doctype, d.name, "cost_center", null);
+ });
+ }
+
+ erpnext.accounts.dimensions.update_dimension(me.frm, me.frm.doctype);
+ }
+
+ stock_items_add(doc, cdt, cdn) {
+ erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'stock_items');
+ }
+
+ asset_items_add(doc, cdt, cdn) {
+ erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'asset_items');
+ }
+
+ serivce_items_add(doc, cdt, cdn) {
+ erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'service_items');
+ }
+
+ get_target_item_details() {
+ var me = this;
+
+ if (me.frm.doc.target_item_code) {
+ return me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_item_details",
+ child: me.frm.doc,
+ args: {
+ item_code: me.frm.doc.target_item_code,
+ company: me.frm.doc.company,
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ me.frm.refresh_fields();
+ }
+ }
+ });
+ }
+ }
+
+ get_target_asset_details() {
+ var me = this;
+
+ if (me.frm.doc.target_asset) {
+ return me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_asset_details",
+ child: me.frm.doc,
+ args: {
+ asset: me.frm.doc.target_asset,
+ company: me.frm.doc.company,
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ me.frm.refresh_fields();
+ }
+ }
+ });
+ }
+ }
+
+ get_consumed_stock_item_details(row) {
+ var me = this;
+
+ if (row && row.item_code) {
+ return me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_consumed_stock_item_details",
+ child: row,
+ args: {
+ args: {
+ item_code: row.item_code,
+ warehouse: row.warehouse,
+ stock_qty: flt(row.stock_qty),
+ doctype: me.frm.doc.doctype,
+ name: me.frm.doc.name,
+ company: me.frm.doc.company,
+ posting_date: me.frm.doc.posting_date,
+ posting_time: me.frm.doc.posting_time,
+ }
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+ }
+
+ get_consumed_asset_details(row) {
+ var me = this;
+
+ if (row && row.asset) {
+ return me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_consumed_asset_details",
+ child: row,
+ args: {
+ args: {
+ asset: row.asset,
+ doctype: me.frm.doc.doctype,
+ name: me.frm.doc.name,
+ company: me.frm.doc.company,
+ finance_book: row.finance_book || me.frm.doc.finance_book,
+ posting_date: me.frm.doc.posting_date,
+ posting_time: me.frm.doc.posting_time,
+ }
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+ }
+
+ get_service_item_details(row) {
+ var me = this;
+
+ if (row && row.item_code) {
+ return me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_service_item_details",
+ child: row,
+ args: {
+ args: {
+ item_code: row.item_code,
+ qty: flt(row.qty),
+ expense_account: row.expense_account,
+ company: me.frm.doc.company,
+ }
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+ }
+
+ get_warehouse_details(item) {
+ var me = this;
+ if (item.item_code && item.warehouse) {
+ me.frm.call({
+ method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_warehouse_details",
+ child: item,
+ args: {
+ args: {
+ 'item_code': item.item_code,
+ 'warehouse': cstr(item.warehouse),
+ 'qty': flt(item.stock_qty),
+ 'serial_no': item.serial_no,
+ 'posting_date': me.frm.doc.posting_date,
+ 'posting_time': me.frm.doc.posting_time,
+ 'company': me.frm.doc.company,
+ 'voucher_type': me.frm.doc.doctype,
+ 'voucher_no': me.frm.doc.name,
+ 'allow_zero_valuation': 1
+ }
+ },
+ callback: function(r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+ }
+
+ get_all_item_warehouse_details() {
+ var me = this;
+ return me.frm.call({
+ method: "set_warehouse_details",
+ doc: me.frm.doc,
+ callback: function(r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+
+ get_all_asset_values() {
+ var me = this;
+ return me.frm.call({
+ method: "set_asset_values",
+ doc: me.frm.doc,
+ callback: function(r) {
+ if (!r.exc) {
+ me.calculate_totals();
+ }
+ }
+ });
+ }
+
+ calculate_totals() {
+ var me = this;
+
+ me.frm.doc.stock_items_total = 0;
+ me.frm.doc.asset_items_total = 0;
+ me.frm.doc.service_items_total = 0;
+
+ $.each(me.frm.doc.stock_items || [], function (i, d) {
+ d.amount = flt(flt(d.stock_qty) * flt(d.valuation_rate), precision('amount', d));
+ me.frm.doc.stock_items_total += d.amount;
+ });
+
+ $.each(me.frm.doc.asset_items || [], function (i, d) {
+ d.asset_value = flt(flt(d.asset_value), precision('asset_value', d));
+ me.frm.doc.asset_items_total += d.asset_value;
+ });
+
+ $.each(me.frm.doc.service_items || [], function (i, d) {
+ d.amount = flt(flt(d.qty) * flt(d.rate), precision('amount', d));
+ me.frm.doc.service_items_total += d.amount;
+ });
+
+ me.frm.doc.stock_items_total = flt(me.frm.doc.stock_items_total, precision('stock_items_total'));
+ me.frm.doc.asset_items_total = flt(me.frm.doc.asset_items_total, precision('asset_items_total'));
+ me.frm.doc.service_items_total = flt(me.frm.doc.service_items_total, precision('service_items_total'));
+
+ me.frm.doc.total_value = me.frm.doc.stock_items_total + me.frm.doc.asset_items_total + me.frm.doc.service_items_total;
+ me.frm.doc.total_value = flt(me.frm.doc.total_value, precision('total_value'));
+
+ me.frm.doc.target_qty = flt(me.frm.doc.target_qty, precision('target_qty'));
+ me.frm.doc.target_incoming_rate = me.frm.doc.target_qty ? me.frm.doc.total_value / flt(me.frm.doc.target_qty)
+ : me.frm.doc.total_value;
+
+ me.frm.refresh_fields();
+ }
+};
+
+cur_frm.cscript = new erpnext.assets.AssetCapitalization({frm: cur_frm});
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json
new file mode 100644
index 0000000000..d1be5752d6
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json
@@ -0,0 +1,381 @@
+{
+ "actions": [],
+ "autoname": "naming_series:",
+ "creation": "2021-09-04 13:38:04.217187",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "naming_series",
+ "entry_type",
+ "target_item_code",
+ "target_item_name",
+ "target_is_fixed_asset",
+ "target_has_batch_no",
+ "target_has_serial_no",
+ "column_break_9",
+ "target_asset",
+ "target_asset_name",
+ "target_warehouse",
+ "target_qty",
+ "target_stock_uom",
+ "target_batch_no",
+ "target_serial_no",
+ "column_break_5",
+ "company",
+ "finance_book",
+ "posting_date",
+ "posting_time",
+ "set_posting_time",
+ "amended_from",
+ "section_break_16",
+ "stock_items",
+ "stock_items_total",
+ "section_break_26",
+ "asset_items",
+ "asset_items_total",
+ "service_expenses_section",
+ "service_items",
+ "service_items_total",
+ "totals_section",
+ "total_value",
+ "column_break_36",
+ "target_incoming_rate",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break",
+ "target_fixed_asset_account"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Title"
+ },
+ {
+ "fieldname": "target_item_code",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Target Item Code",
+ "options": "Item",
+ "reqd": 1
+ },
+ {
+ "depends_on": "eval:doc.target_item_code && doc.target_item_name != doc.target_item_code",
+ "fetch_from": "target_item_code.item_name",
+ "fieldname": "target_item_name",
+ "fieldtype": "Data",
+ "label": "Target Item Name",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fetch_from": "target_item_code.is_fixed_asset",
+ "fieldname": "target_is_fixed_asset",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Target Is Fixed Asset",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_5",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.entry_type=='Capitalization'",
+ "fieldname": "target_asset",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Target Asset",
+ "mandatory_depends_on": "eval:doc.entry_type=='Capitalization'",
+ "no_copy": 1,
+ "options": "Asset"
+ },
+ {
+ "depends_on": "eval:doc.entry_type=='Capitalization'",
+ "fetch_from": "target_asset.asset_name",
+ "fieldname": "target_asset_name",
+ "fieldtype": "Data",
+ "label": "Asset Name",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "asset.company",
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ },
+ {
+ "default": "Today",
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Posting Date",
+ "no_copy": 1,
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "default": "Now",
+ "fieldname": "posting_time",
+ "fieldtype": "Time",
+ "label": "Posting Time",
+ "no_copy": 1,
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.docstatus==0",
+ "fieldname": "set_posting_time",
+ "fieldtype": "Check",
+ "label": "Edit Posting Date and Time"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Series",
+ "options": "ACC-ASC-.YYYY.-",
+ "reqd": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Asset Capitalization",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval:doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length)",
+ "fieldname": "section_break_16",
+ "fieldtype": "Section Break",
+ "label": "Consumed Stock Items"
+ },
+ {
+ "fieldname": "stock_items",
+ "fieldtype": "Table",
+ "label": "Stock Items",
+ "options": "Asset Capitalization Stock Item"
+ },
+ {
+ "depends_on": "eval:doc.entry_type=='Decapitalization'",
+ "fieldname": "target_warehouse",
+ "fieldtype": "Link",
+ "label": "Target Warehouse",
+ "mandatory_depends_on": "eval:doc.entry_type=='Decapitalization'",
+ "options": "Warehouse"
+ },
+ {
+ "depends_on": "target_has_batch_no",
+ "fieldname": "target_batch_no",
+ "fieldtype": "Link",
+ "label": "Target Batch No",
+ "options": "Batch"
+ },
+ {
+ "default": "1",
+ "fieldname": "target_qty",
+ "fieldtype": "Float",
+ "label": "Target Qty",
+ "read_only_depends_on": "target_is_fixed_asset"
+ },
+ {
+ "fetch_from": "target_item_code.stock_uom",
+ "fieldname": "target_stock_uom",
+ "fieldtype": "Link",
+ "label": "Stock UOM",
+ "options": "UOM",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fetch_from": "target_item_code.has_batch_no",
+ "fieldname": "target_has_batch_no",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Target Has Batch No",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fetch_from": "target_item_code.has_serial_no",
+ "fieldname": "target_has_serial_no",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Target Has Serial No",
+ "read_only": 1
+ },
+ {
+ "depends_on": "target_has_serial_no",
+ "fieldname": "target_serial_no",
+ "fieldtype": "Small Text",
+ "label": "Target Serial No"
+ },
+ {
+ "depends_on": "eval:doc.docstatus == 0 || (doc.asset_items && doc.asset_items.length)",
+ "fieldname": "section_break_26",
+ "fieldtype": "Section Break",
+ "label": "Consumed Asset Items"
+ },
+ {
+ "fieldname": "asset_items",
+ "fieldtype": "Table",
+ "label": "Assets",
+ "options": "Asset Capitalization Asset Item"
+ },
+ {
+ "default": "Capitalization",
+ "fieldname": "entry_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Entry Type",
+ "options": "Capitalization\nDecapitalization",
+ "reqd": 1
+ },
+ {
+ "fieldname": "stock_items_total",
+ "fieldtype": "Currency",
+ "label": "Consumed Stock Total Value",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "asset_items_total",
+ "fieldtype": "Currency",
+ "label": "Consumed Asset Total Value",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "finance_book",
+ "fieldtype": "Link",
+ "label": "Finance Book",
+ "options": "Finance Book"
+ },
+ {
+ "depends_on": "eval:doc.docstatus == 0 || (doc.service_items && doc.service_items.length)",
+ "fieldname": "service_expenses_section",
+ "fieldtype": "Section Break",
+ "label": "Service Expenses"
+ },
+ {
+ "fieldname": "service_items",
+ "fieldtype": "Table",
+ "label": "Services",
+ "options": "Asset Capitalization Service Item"
+ },
+ {
+ "fieldname": "service_items_total",
+ "fieldtype": "Currency",
+ "label": "Service Expense Total Amount",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "totals_section",
+ "fieldtype": "Section Break",
+ "label": "Totals"
+ },
+ {
+ "fieldname": "total_value",
+ "fieldtype": "Currency",
+ "label": "Total Value",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_36",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "target_incoming_rate",
+ "fieldtype": "Currency",
+ "label": "Target Incoming Rate",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "target_fixed_asset_account",
+ "fieldtype": "Link",
+ "label": "Target Fixed Asset Account",
+ "options": "Account",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-09-12 15:09:40.771332",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Asset Capitalization",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Manufacturing Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Quality Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
+ "track_changes": 1,
+ "track_seen": 1
+}
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
new file mode 100644
index 0000000000..5b910dbb2e
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -0,0 +1,773 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+
+# import erpnext
+from frappe import _
+from frappe.utils import cint, flt, get_link_to_form
+
+import erpnext
+from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
+from erpnext.assets.doctype.asset.depreciation import (
+ depreciate_asset,
+ get_gl_entries_on_asset_disposal,
+ get_value_after_depreciation_on_disposal_date,
+ reset_depreciation_schedule,
+ reverse_depreciation_entry_made_after_disposal,
+)
+from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ make_new_active_asset_depr_schedules_and_cancel_current_ones,
+)
+from erpnext.controllers.stock_controller import StockController
+from erpnext.setup.doctype.brand.brand import get_brand_defaults
+from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
+from erpnext.stock import get_warehouse_account_map
+from erpnext.stock.doctype.item.item import get_item_defaults
+from erpnext.stock.get_item_details import (
+ get_default_cost_center,
+ get_default_expense_account,
+ get_item_warehouse,
+)
+from erpnext.stock.stock_ledger import get_previous_sle
+from erpnext.stock.utils import get_incoming_rate
+
+force_fields = [
+ "target_item_name",
+ "target_asset_name",
+ "item_name",
+ "asset_name",
+ "target_is_fixed_asset",
+ "target_has_serial_no",
+ "target_has_batch_no",
+ "target_stock_uom",
+ "stock_uom",
+ "target_fixed_asset_account",
+ "fixed_asset_account",
+ "valuation_rate",
+]
+
+
+class AssetCapitalization(StockController):
+ def validate(self):
+ self.validate_posting_time()
+ self.set_missing_values(for_validate=True)
+ self.validate_target_item()
+ self.validate_target_asset()
+ self.validate_consumed_stock_item()
+ self.validate_consumed_asset_item()
+ self.validate_service_item()
+ self.set_warehouse_details()
+ self.set_asset_values()
+ self.calculate_totals()
+ self.set_title()
+
+ def before_submit(self):
+ self.validate_source_mandatory()
+
+ def on_submit(self):
+ self.update_stock_ledger()
+ self.make_gl_entries()
+ self.update_target_asset()
+
+ def on_cancel(self):
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ self.update_stock_ledger()
+ self.make_gl_entries()
+ self.update_target_asset()
+
+ def set_title(self):
+ self.title = self.target_asset_name or self.target_item_name or self.target_item_code
+
+ def set_missing_values(self, for_validate=False):
+ target_item_details = get_target_item_details(self.target_item_code, self.company)
+ for k, v in target_item_details.items():
+ if self.meta.has_field(k) and (not self.get(k) or k in force_fields):
+ self.set(k, v)
+
+ # Remove asset if item not a fixed asset
+ if not self.target_is_fixed_asset:
+ self.target_asset = None
+
+ target_asset_details = get_target_asset_details(self.target_asset, self.company)
+ for k, v in target_asset_details.items():
+ if self.meta.has_field(k) and (not self.get(k) or k in force_fields):
+ self.set(k, v)
+
+ for d in self.stock_items:
+ args = self.as_dict()
+ args.update(d.as_dict())
+ args.doctype = self.doctype
+ args.name = self.name
+ consumed_stock_item_details = get_consumed_stock_item_details(args)
+ for k, v in consumed_stock_item_details.items():
+ if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
+ d.set(k, v)
+
+ for d in self.asset_items:
+ args = self.as_dict()
+ args.update(d.as_dict())
+ args.doctype = self.doctype
+ args.name = self.name
+ args.finance_book = d.get("finance_book") or self.get("finance_book")
+ consumed_asset_details = get_consumed_asset_details(args)
+ for k, v in consumed_asset_details.items():
+ if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
+ d.set(k, v)
+
+ for d in self.service_items:
+ args = self.as_dict()
+ args.update(d.as_dict())
+ args.doctype = self.doctype
+ args.name = self.name
+ service_item_details = get_service_item_details(args)
+ for k, v in service_item_details.items():
+ if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
+ d.set(k, v)
+
+ def validate_target_item(self):
+ target_item = frappe.get_cached_doc("Item", self.target_item_code)
+
+ if not target_item.is_fixed_asset and not target_item.is_stock_item:
+ frappe.throw(
+ _("Target Item {0} is neither a Fixed Asset nor a Stock Item").format(target_item.name)
+ )
+
+ if self.entry_type == "Capitalization" and not target_item.is_fixed_asset:
+ frappe.throw(_("Target Item {0} must be a Fixed Asset item").format(target_item.name))
+ elif self.entry_type == "Decapitalization" and not target_item.is_stock_item:
+ frappe.throw(_("Target Item {0} must be a Stock Item").format(target_item.name))
+
+ if target_item.is_fixed_asset:
+ self.target_qty = 1
+ if flt(self.target_qty) <= 0:
+ frappe.throw(_("Target Qty must be a positive number"))
+
+ if not target_item.is_stock_item:
+ self.target_warehouse = None
+ if not target_item.is_fixed_asset:
+ self.target_asset = None
+ self.target_fixed_asset_account = None
+ if not target_item.has_batch_no:
+ self.target_batch_no = None
+ if not target_item.has_serial_no:
+ self.target_serial_no = ""
+
+ if target_item.is_stock_item and not self.target_warehouse:
+ frappe.throw(_("Target Warehouse is mandatory for Decapitalization"))
+
+ self.validate_item(target_item)
+
+ def validate_target_asset(self):
+ if self.target_asset:
+ target_asset = self.get_asset_for_validation(self.target_asset)
+
+ if target_asset.item_code != self.target_item_code:
+ frappe.throw(
+ _("Asset {0} does not belong to Item {1}").format(self.target_asset, self.target_item_code)
+ )
+
+ self.validate_asset(target_asset)
+
+ def validate_consumed_stock_item(self):
+ for d in self.stock_items:
+ if d.item_code:
+ item = frappe.get_cached_doc("Item", d.item_code)
+
+ if not item.is_stock_item:
+ frappe.throw(_("Row #{0}: Item {1} is not a stock item").format(d.idx, d.item_code))
+
+ if flt(d.stock_qty) <= 0:
+ frappe.throw(_("Row #{0}: Qty must be a positive number").format(d.idx))
+
+ self.validate_item(item)
+
+ def validate_consumed_asset_item(self):
+ for d in self.asset_items:
+ if d.asset:
+ if d.asset == self.target_asset:
+ frappe.throw(
+ _("Row #{0}: Consumed Asset {1} cannot be the same as the Target Asset").format(
+ d.idx, d.asset
+ )
+ )
+
+ asset = self.get_asset_for_validation(d.asset)
+ self.validate_asset(asset)
+
+ def validate_service_item(self):
+ for d in self.service_items:
+ if d.item_code:
+ item = frappe.get_cached_doc("Item", d.item_code)
+
+ if item.is_stock_item or item.is_fixed_asset:
+ frappe.throw(_("Row #{0}: Item {1} is not a service item").format(d.idx, d.item_code))
+
+ if flt(d.qty) <= 0:
+ frappe.throw(_("Row #{0}: Qty must be a positive number").format(d.idx))
+
+ if flt(d.rate) <= 0:
+ frappe.throw(_("Row #{0}: Amount must be a positive number").format(d.idx))
+
+ self.validate_item(item)
+
+ if not d.cost_center:
+ d.cost_center = frappe.get_cached_value("Company", self.company, "cost_center")
+
+ def validate_source_mandatory(self):
+ if not self.target_is_fixed_asset and not self.get("asset_items"):
+ frappe.throw(_("Consumed Asset Items is mandatory for Decapitalization"))
+
+ if not self.get("stock_items") and not self.get("asset_items"):
+ frappe.throw(_("Consumed Stock Items or Consumed Asset Items is mandatory for Capitalization"))
+
+ def validate_item(self, item):
+ from erpnext.stock.doctype.item.item import validate_end_of_life
+
+ validate_end_of_life(item.name, item.end_of_life, item.disabled)
+
+ def get_asset_for_validation(self, asset):
+ return frappe.db.get_value(
+ "Asset", asset, ["name", "item_code", "company", "status", "docstatus"], as_dict=1
+ )
+
+ def validate_asset(self, asset):
+ if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"):
+ frappe.throw(_("Asset {0} is {1}").format(asset.name, asset.status))
+
+ if asset.docstatus == 0:
+ frappe.throw(_("Asset {0} is Draft").format(asset.name))
+ if asset.docstatus == 2:
+ frappe.throw(_("Asset {0} is cancelled").format(asset.name))
+
+ if asset.company != self.company:
+ frappe.throw(_("Asset {0} does not belong to company {1}").format(asset.name, self.company))
+
+ @frappe.whitelist()
+ def set_warehouse_details(self):
+ for d in self.get("stock_items"):
+ if d.item_code and d.warehouse:
+ args = self.get_args_for_incoming_rate(d)
+ warehouse_details = get_warehouse_details(args)
+ d.update(warehouse_details)
+
+ @frappe.whitelist()
+ def set_asset_values(self):
+ for d in self.get("asset_items"):
+ if d.asset:
+ finance_book = d.get("finance_book") or self.get("finance_book")
+ d.current_asset_value = flt(
+ get_asset_value_after_depreciation(d.asset, finance_book=finance_book)
+ )
+ d.asset_value = get_value_after_depreciation_on_disposal_date(
+ d.asset, self.posting_date, finance_book=finance_book
+ )
+
+ def get_args_for_incoming_rate(self, item):
+ return frappe._dict(
+ {
+ "item_code": item.item_code,
+ "warehouse": item.warehouse,
+ "posting_date": self.posting_date,
+ "posting_time": self.posting_time,
+ "qty": -1 * flt(item.stock_qty),
+ "serial_no": item.serial_no,
+ "batch_no": item.batch_no,
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "company": self.company,
+ "allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")),
+ }
+ )
+
+ def calculate_totals(self):
+ self.stock_items_total = 0
+ self.asset_items_total = 0
+ self.service_items_total = 0
+
+ for d in self.stock_items:
+ d.amount = flt(flt(d.stock_qty) * flt(d.valuation_rate), d.precision("amount"))
+ self.stock_items_total += d.amount
+
+ for d in self.asset_items:
+ d.asset_value = flt(flt(d.asset_value), d.precision("asset_value"))
+ self.asset_items_total += d.asset_value
+
+ for d in self.service_items:
+ d.amount = flt(flt(d.qty) * flt(d.rate), d.precision("amount"))
+ self.service_items_total += d.amount
+
+ self.stock_items_total = flt(self.stock_items_total, self.precision("stock_items_total"))
+ self.asset_items_total = flt(self.asset_items_total, self.precision("asset_items_total"))
+ self.service_items_total = flt(self.service_items_total, self.precision("service_items_total"))
+
+ self.total_value = self.stock_items_total + self.asset_items_total + self.service_items_total
+ self.total_value = flt(self.total_value, self.precision("total_value"))
+
+ self.target_qty = flt(self.target_qty, self.precision("target_qty"))
+ self.target_incoming_rate = self.total_value / self.target_qty
+
+ def update_stock_ledger(self):
+ sl_entries = []
+
+ for d in self.stock_items:
+ sle = self.get_sl_entries(
+ d,
+ {
+ "actual_qty": -flt(d.stock_qty),
+ },
+ )
+ sl_entries.append(sle)
+
+ if self.entry_type == "Decapitalization" and not self.target_is_fixed_asset:
+ sle = self.get_sl_entries(
+ self,
+ {
+ "item_code": self.target_item_code,
+ "warehouse": self.target_warehouse,
+ "batch_no": self.target_batch_no,
+ "serial_no": self.target_serial_no,
+ "actual_qty": flt(self.target_qty),
+ "incoming_rate": flt(self.target_incoming_rate),
+ },
+ )
+ sl_entries.append(sle)
+
+ # reverse sl entries if cancel
+ if self.docstatus == 2:
+ sl_entries.reverse()
+
+ if sl_entries:
+ self.make_sl_entries(sl_entries)
+
+ def make_gl_entries(self, gl_entries=None, from_repost=False):
+ from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
+
+ if self.docstatus == 1:
+ if not gl_entries:
+ gl_entries = self.get_gl_entries()
+
+ if gl_entries:
+ make_gl_entries(gl_entries, from_repost=from_repost)
+ elif self.docstatus == 2:
+ make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
+
+ def get_gl_entries(
+ self, warehouse_account=None, default_expense_account=None, default_cost_center=None
+ ):
+ # Stock GL Entries
+ gl_entries = []
+
+ self.warehouse_account = warehouse_account
+ if not self.warehouse_account:
+ self.warehouse_account = get_warehouse_account_map(self.company)
+
+ precision = self.get_debit_field_precision()
+ self.sle_map = self.get_stock_ledger_details()
+
+ target_account = self.get_target_account()
+ target_against = set()
+
+ self.get_gl_entries_for_consumed_stock_items(
+ gl_entries, target_account, target_against, precision
+ )
+ self.get_gl_entries_for_consumed_asset_items(
+ gl_entries, target_account, target_against, precision
+ )
+ self.get_gl_entries_for_consumed_service_items(
+ gl_entries, target_account, target_against, precision
+ )
+
+ self.get_gl_entries_for_target_item(gl_entries, target_against, precision)
+ return gl_entries
+
+ def get_target_account(self):
+ if self.target_is_fixed_asset:
+ return self.target_fixed_asset_account
+ else:
+ return self.warehouse_account[self.target_warehouse]["account"]
+
+ def get_gl_entries_for_consumed_stock_items(
+ self, gl_entries, target_account, target_against, precision
+ ):
+ # Consumed Stock Items
+ for item_row in self.stock_items:
+ sle_list = self.sle_map.get(item_row.name)
+ if sle_list:
+ for sle in sle_list:
+ stock_value_difference = flt(sle.stock_value_difference, precision)
+
+ if erpnext.is_perpetual_inventory_enabled(self.company):
+ account = self.warehouse_account[sle.warehouse]["account"]
+ else:
+ account = self.get_company_default("default_expense_account")
+
+ target_against.add(account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": target_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.get("project") or self.get("project"),
+ "remarks": self.get("remarks") or "Accounting Entry for Stock",
+ "credit": -1 * stock_value_difference,
+ },
+ self.warehouse_account[sle.warehouse]["account_currency"],
+ item=item_row,
+ )
+ )
+
+ def get_gl_entries_for_consumed_asset_items(
+ self, gl_entries, target_account, target_against, precision
+ ):
+ # Consumed Assets
+ for item in self.asset_items:
+ asset = self.get_asset(item)
+
+ if asset.calculate_depreciation:
+ notes = _(
+ "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name), get_link_to_form(self.doctype, self.get("name"))
+ )
+ depreciate_asset(asset, self.posting_date, notes)
+ asset.reload()
+
+ fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
+ asset,
+ item.asset_value,
+ item.get("finance_book") or self.get("finance_book"),
+ self.get("doctype"),
+ self.get("name"),
+ )
+
+ asset.db_set("disposal_date", self.posting_date)
+
+ self.set_consumed_asset_status(asset)
+
+ for gle in fixed_asset_gl_entries:
+ gle["against"] = target_account
+ gl_entries.append(self.get_gl_dict(gle, item=item))
+ target_against.add(gle["account"])
+
+ def get_gl_entries_for_consumed_service_items(
+ self, gl_entries, target_account, target_against, precision
+ ):
+ # Service Expenses
+ for item_row in self.service_items:
+ expense_amount = flt(item_row.amount, precision)
+ target_against.add(item_row.expense_account)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item_row.expense_account,
+ "against": target_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.get("project") or self.get("project"),
+ "remarks": self.get("remarks") or "Accounting Entry for Stock",
+ "credit": expense_amount,
+ },
+ item=item_row,
+ )
+ )
+
+ def get_gl_entries_for_target_item(self, gl_entries, target_against, precision):
+ if self.target_is_fixed_asset:
+ # Capitalization
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": self.target_fixed_asset_account,
+ "against": ", ".join(target_against),
+ "remarks": self.get("remarks") or _("Accounting Entry for Asset"),
+ "debit": flt(self.total_value, precision),
+ "cost_center": self.get("cost_center"),
+ },
+ item=self,
+ )
+ )
+ else:
+ # Target Stock Item
+ sle_list = self.sle_map.get(self.name)
+ for sle in sle_list:
+ stock_value_difference = flt(sle.stock_value_difference, precision)
+ account = self.warehouse_account[sle.warehouse]["account"]
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": ", ".join(target_against),
+ "cost_center": self.cost_center,
+ "project": self.get("project"),
+ "remarks": self.get("remarks") or "Accounting Entry for Stock",
+ "debit": stock_value_difference,
+ },
+ self.warehouse_account[sle.warehouse]["account_currency"],
+ item=self,
+ )
+ )
+
+ def update_target_asset(self):
+ total_target_asset_value = flt(self.total_value, self.precision("total_value"))
+ if self.docstatus == 1 and self.entry_type == "Capitalization":
+ asset_doc = frappe.get_doc("Asset", self.target_asset)
+ asset_doc.purchase_date = self.posting_date
+ asset_doc.gross_purchase_amount = total_target_asset_value
+ asset_doc.purchase_receipt_amount = total_target_asset_value
+ notes = _(
+ "This schedule was created when target Asset {0} was updated through Asset Capitalization {1}."
+ ).format(
+ get_link_to_form(asset_doc.doctype, asset_doc.name), get_link_to_form(self.doctype, self.name)
+ )
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(asset_doc, notes)
+ asset_doc.flags.ignore_validate_update_after_submit = True
+ asset_doc.save()
+ elif self.docstatus == 2:
+ for item in self.asset_items:
+ asset = self.get_asset(item)
+ asset.db_set("disposal_date", None)
+ self.set_consumed_asset_status(asset)
+
+ if asset.calculate_depreciation:
+ reverse_depreciation_entry_made_after_disposal(asset, self.posting_date)
+ notes = _(
+ "This schedule was created when Asset {0} was restored on Asset Capitalization {1}'s cancellation."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name), get_link_to_form(self.doctype, self.name)
+ )
+ reset_depreciation_schedule(asset, self.posting_date, notes)
+
+ def get_asset(self, item):
+ asset = frappe.get_doc("Asset", item.asset)
+ self.check_finance_books(item, asset)
+ return asset
+
+ def set_consumed_asset_status(self, asset):
+ if self.docstatus == 1:
+ asset.set_status("Capitalized" if self.target_is_fixed_asset else "Decapitalized")
+ else:
+ asset.set_status()
+
+
+@frappe.whitelist()
+def get_target_item_details(item_code=None, company=None):
+ out = frappe._dict()
+
+ # Get Item Details
+ item = frappe._dict()
+ if item_code:
+ item = frappe.get_cached_doc("Item", item_code)
+
+ # Set Item Details
+ out.target_item_name = item.item_name
+ out.target_stock_uom = item.stock_uom
+ out.target_is_fixed_asset = cint(item.is_fixed_asset)
+ out.target_has_batch_no = cint(item.has_batch_no)
+ out.target_has_serial_no = cint(item.has_serial_no)
+
+ if out.target_is_fixed_asset:
+ out.target_qty = 1
+ out.target_warehouse = None
+ else:
+ out.target_asset = None
+
+ if not out.target_has_batch_no:
+ out.target_batch_no = None
+ if not out.target_has_serial_no:
+ out.target_serial_no = ""
+
+ # Cost Center
+ item_defaults = get_item_defaults(item.name, company)
+ item_group_defaults = get_item_group_defaults(item.name, company)
+ brand_defaults = get_brand_defaults(item.name, company)
+ out.cost_center = get_default_cost_center(
+ frappe._dict({"item_code": item.name, "company": company}),
+ item_defaults,
+ item_group_defaults,
+ brand_defaults,
+ )
+
+ return out
+
+
+@frappe.whitelist()
+def get_target_asset_details(asset=None, company=None):
+ out = frappe._dict()
+
+ # Get Asset Details
+ asset_details = frappe._dict()
+ if asset:
+ asset_details = frappe.db.get_value("Asset", asset, ["asset_name", "item_code"], as_dict=1)
+ if not asset_details:
+ frappe.throw(_("Asset {0} does not exist").format(asset))
+
+ # Re-set item code from Asset
+ out.target_item_code = asset_details.item_code
+
+ # Set Asset Details
+ out.asset_name = asset_details.asset_name
+
+ if asset_details.item_code:
+ out.target_fixed_asset_account = get_asset_category_account(
+ "fixed_asset_account", item=asset_details.item_code, company=company
+ )
+ else:
+ out.target_fixed_asset_account = None
+
+ return out
+
+
+@frappe.whitelist()
+def get_consumed_stock_item_details(args):
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ args = frappe._dict(args)
+ out = frappe._dict()
+
+ item = frappe._dict()
+ if args.item_code:
+ item = frappe.get_cached_doc("Item", args.item_code)
+
+ out.item_name = item.item_name
+ out.batch_no = None
+ out.serial_no = ""
+
+ out.stock_qty = flt(args.stock_qty) or 1
+ out.stock_uom = item.stock_uom
+
+ out.warehouse = get_item_warehouse(item, args, overwrite_warehouse=True) if item else None
+
+ # Cost Center
+ item_defaults = get_item_defaults(item.name, args.company)
+ item_group_defaults = get_item_group_defaults(item.name, args.company)
+ brand_defaults = get_brand_defaults(item.name, args.company)
+ out.cost_center = get_default_cost_center(
+ args, item_defaults, item_group_defaults, brand_defaults
+ )
+
+ if args.item_code and out.warehouse:
+ incoming_rate_args = frappe._dict(
+ {
+ "item_code": args.item_code,
+ "warehouse": out.warehouse,
+ "posting_date": args.posting_date,
+ "posting_time": args.posting_time,
+ "qty": -1 * flt(out.stock_qty),
+ "voucher_type": args.doctype,
+ "voucher_no": args.name,
+ "company": args.company,
+ "serial_no": args.serial_no,
+ "batch_no": args.batch_no,
+ }
+ )
+ out.update(get_warehouse_details(incoming_rate_args))
+ else:
+ out.valuation_rate = 0
+ out.actual_qty = 0
+
+ return out
+
+
+@frappe.whitelist()
+def get_warehouse_details(args):
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ args = frappe._dict(args)
+
+ out = {}
+ if args.warehouse and args.item_code:
+ out = {
+ "actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
+ "valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
+ }
+ return out
+
+
+@frappe.whitelist()
+def get_consumed_asset_details(args):
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ args = frappe._dict(args)
+ out = frappe._dict()
+
+ asset_details = frappe._dict()
+ if args.asset:
+ asset_details = frappe.db.get_value(
+ "Asset", args.asset, ["asset_name", "item_code", "item_name"], as_dict=1
+ )
+ if not asset_details:
+ frappe.throw(_("Asset {0} does not exist").format(args.asset))
+
+ out.item_code = asset_details.item_code
+ out.asset_name = asset_details.asset_name
+ out.item_name = asset_details.item_name
+
+ if args.asset:
+ out.current_asset_value = flt(
+ get_asset_value_after_depreciation(args.asset, finance_book=args.finance_book)
+ )
+ out.asset_value = get_value_after_depreciation_on_disposal_date(
+ args.asset, args.posting_date, finance_book=args.finance_book
+ )
+ else:
+ out.current_asset_value = 0
+ out.asset_value = 0
+
+ # Account
+ if asset_details.item_code:
+ out.fixed_asset_account = get_asset_category_account(
+ "fixed_asset_account", item=asset_details.item_code, company=args.company
+ )
+ else:
+ out.fixed_asset_account = None
+
+ # Cost Center
+ if asset_details.item_code:
+ item = frappe.get_cached_doc("Item", asset_details.item_code)
+ item_defaults = get_item_defaults(item.name, args.company)
+ item_group_defaults = get_item_group_defaults(item.name, args.company)
+ brand_defaults = get_brand_defaults(item.name, args.company)
+ out.cost_center = get_default_cost_center(
+ args, item_defaults, item_group_defaults, brand_defaults
+ )
+
+ return out
+
+
+@frappe.whitelist()
+def get_service_item_details(args):
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ args = frappe._dict(args)
+ out = frappe._dict()
+
+ item = frappe._dict()
+ if args.item_code:
+ item = frappe.get_cached_doc("Item", args.item_code)
+
+ out.item_name = item.item_name
+ out.qty = flt(args.qty) or 1
+ out.uom = item.purchase_uom or item.stock_uom
+
+ item_defaults = get_item_defaults(item.name, args.company)
+ item_group_defaults = get_item_group_defaults(item.name, args.company)
+ brand_defaults = get_brand_defaults(item.name, args.company)
+
+ out.expense_account = get_default_expense_account(
+ args, item_defaults, item_group_defaults, brand_defaults
+ )
+ out.cost_center = get_default_cost_center(
+ args, item_defaults, item_group_defaults, brand_defaults
+ )
+
+ return out
diff --git a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py
new file mode 100644
index 0000000000..4d519a60be
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py
@@ -0,0 +1,510 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import unittest
+
+import frappe
+from frappe.utils import cint, flt, getdate, now_datetime
+
+from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
+from erpnext.assets.doctype.asset.test_asset import (
+ create_asset,
+ create_asset_data,
+ set_depreciation_settings_in_company,
+)
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+)
+from erpnext.stock.doctype.item.test_item import create_item
+
+
+class TestAssetCapitalization(unittest.TestCase):
+ def setUp(self):
+ set_depreciation_settings_in_company()
+ create_asset_data()
+ create_asset_capitalization_data()
+ frappe.db.sql("delete from `tabTax Rule`")
+
+ def test_capitalization_with_perpetual_inventory(self):
+ company = "_Test Company with perpetual inventory"
+ set_depreciation_settings_in_company(company=company)
+
+ # Variables
+ consumed_asset_value = 100000
+
+ stock_rate = 1000
+ stock_qty = 2
+ stock_amount = 2000
+
+ service_rate = 500
+ service_qty = 2
+ service_amount = 1000
+
+ total_amount = 103000
+
+ # Create assets
+ target_asset = create_asset(
+ asset_name="Asset Capitalization Target Asset",
+ submit=1,
+ warehouse="Stores - TCP1",
+ company=company,
+ )
+ consumed_asset = create_asset(
+ asset_name="Asset Capitalization Consumable Asset",
+ asset_value=consumed_asset_value,
+ submit=1,
+ warehouse="Stores - TCP1",
+ company=company,
+ )
+
+ # Create and submit Asset Captitalization
+ asset_capitalization = create_asset_capitalization(
+ entry_type="Capitalization",
+ target_asset=target_asset.name,
+ stock_qty=stock_qty,
+ stock_rate=stock_rate,
+ consumed_asset=consumed_asset.name,
+ service_qty=service_qty,
+ service_rate=service_rate,
+ service_expense_account="Expenses Included In Asset Valuation - TCP1",
+ company=company,
+ submit=1,
+ )
+
+ # Test Asset Capitalization values
+ self.assertEqual(asset_capitalization.entry_type, "Capitalization")
+ self.assertEqual(asset_capitalization.target_qty, 1)
+
+ self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
+ self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount)
+ self.assertEqual(asset_capitalization.stock_items_total, stock_amount)
+
+ self.assertEqual(asset_capitalization.asset_items[0].asset_value, consumed_asset_value)
+ self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value)
+
+ self.assertEqual(asset_capitalization.service_items[0].amount, service_amount)
+ self.assertEqual(asset_capitalization.service_items_total, service_amount)
+
+ self.assertEqual(asset_capitalization.total_value, total_amount)
+ self.assertEqual(asset_capitalization.target_incoming_rate, total_amount)
+
+ # Test Target Asset values
+ target_asset.reload()
+ self.assertEqual(target_asset.gross_purchase_amount, total_amount)
+ self.assertEqual(target_asset.purchase_receipt_amount, total_amount)
+
+ # Test Consumed Asset values
+ self.assertEqual(consumed_asset.db_get("status"), "Capitalized")
+
+ # Test General Ledger Entries
+ expected_gle = {
+ "_Test Fixed Asset - TCP1": 3000,
+ "Expenses Included In Asset Valuation - TCP1": -1000,
+ "_Test Warehouse - TCP1": -2000,
+ }
+ actual_gle = get_actual_gle_dict(asset_capitalization.name)
+
+ self.assertEqual(actual_gle, expected_gle)
+
+ # Test Stock Ledger Entries
+ expected_sle = {
+ ("Capitalization Source Stock Item", "_Test Warehouse - TCP1"): {
+ "actual_qty": -stock_qty,
+ "stock_value_difference": -stock_amount,
+ }
+ }
+ actual_sle = get_actual_sle_dict(asset_capitalization.name)
+ self.assertEqual(actual_sle, expected_sle)
+
+ # Cancel Asset Capitalization and make test entries and status are reversed
+ asset_capitalization.cancel()
+ self.assertEqual(consumed_asset.db_get("status"), "Submitted")
+ self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
+ self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
+
+ def test_capitalization_with_periodical_inventory(self):
+ company = "_Test Company"
+ # Variables
+ consumed_asset_value = 100000
+
+ stock_rate = 1000
+ stock_qty = 2
+ stock_amount = 2000
+
+ service_rate = 500
+ service_qty = 2
+ service_amount = 1000
+
+ total_amount = 103000
+
+ # Create assets
+ target_asset = create_asset(
+ asset_name="Asset Capitalization Target Asset",
+ submit=1,
+ warehouse="Stores - _TC",
+ company=company,
+ )
+ consumed_asset = create_asset(
+ asset_name="Asset Capitalization Consumable Asset",
+ asset_value=consumed_asset_value,
+ submit=1,
+ warehouse="Stores - _TC",
+ company=company,
+ )
+
+ # Create and submit Asset Captitalization
+ asset_capitalization = create_asset_capitalization(
+ entry_type="Capitalization",
+ target_asset=target_asset.name,
+ stock_qty=stock_qty,
+ stock_rate=stock_rate,
+ consumed_asset=consumed_asset.name,
+ service_qty=service_qty,
+ service_rate=service_rate,
+ service_expense_account="Expenses Included In Asset Valuation - _TC",
+ company=company,
+ submit=1,
+ )
+
+ # Test Asset Capitalization values
+ self.assertEqual(asset_capitalization.entry_type, "Capitalization")
+ self.assertEqual(asset_capitalization.target_qty, 1)
+
+ self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
+ self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount)
+ self.assertEqual(asset_capitalization.stock_items_total, stock_amount)
+
+ self.assertEqual(asset_capitalization.asset_items[0].asset_value, consumed_asset_value)
+ self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value)
+
+ self.assertEqual(asset_capitalization.service_items[0].amount, service_amount)
+ self.assertEqual(asset_capitalization.service_items_total, service_amount)
+
+ self.assertEqual(asset_capitalization.total_value, total_amount)
+ self.assertEqual(asset_capitalization.target_incoming_rate, total_amount)
+
+ # Test Target Asset values
+ target_asset.reload()
+ self.assertEqual(target_asset.gross_purchase_amount, total_amount)
+ self.assertEqual(target_asset.purchase_receipt_amount, total_amount)
+
+ # Test Consumed Asset values
+ self.assertEqual(consumed_asset.db_get("status"), "Capitalized")
+
+ # Test General Ledger Entries
+ default_expense_account = frappe.db.get_value("Company", company, "default_expense_account")
+ expected_gle = {
+ "_Test Fixed Asset - _TC": 3000,
+ "Expenses Included In Asset Valuation - _TC": -1000,
+ default_expense_account: -2000,
+ }
+ actual_gle = get_actual_gle_dict(asset_capitalization.name)
+
+ self.assertEqual(actual_gle, expected_gle)
+
+ # Test Stock Ledger Entries
+ expected_sle = {
+ ("Capitalization Source Stock Item", "_Test Warehouse - _TC"): {
+ "actual_qty": -stock_qty,
+ "stock_value_difference": -stock_amount,
+ }
+ }
+ actual_sle = get_actual_sle_dict(asset_capitalization.name)
+ self.assertEqual(actual_sle, expected_sle)
+
+ # Cancel Asset Capitalization and make test entries and status are reversed
+ asset_capitalization.cancel()
+ self.assertEqual(consumed_asset.db_get("status"), "Submitted")
+ self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
+ self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
+
+ def test_decapitalization_with_depreciation(self):
+ # Variables
+ purchase_date = "2020-01-01"
+ depreciation_start_date = "2020-12-31"
+ capitalization_date = "2021-06-30"
+
+ total_number_of_depreciations = 3
+ expected_value_after_useful_life = 10_000
+ consumed_asset_purchase_value = 100_000
+ consumed_asset_current_value = 70_000
+ consumed_asset_value_before_disposal = 55_000
+
+ target_qty = 10
+ target_incoming_rate = 5500
+
+ depreciation_before_disposal_amount = 15_000
+ accumulated_depreciation = 45_000
+
+ # to accomodate for depreciation on disposal calculation minor difference
+ consumed_asset_value_before_disposal = 55_123.29
+ target_incoming_rate = 5512.329
+ depreciation_before_disposal_amount = 14_876.71
+ accumulated_depreciation = 44_876.71
+
+ # Create assets
+ consumed_asset = create_depreciation_asset(
+ asset_name="Asset Capitalization Consumable Asset",
+ asset_value=consumed_asset_purchase_value,
+ purchase_date=purchase_date,
+ depreciation_start_date=depreciation_start_date,
+ depreciation_method="Straight Line",
+ total_number_of_depreciations=total_number_of_depreciations,
+ frequency_of_depreciation=12,
+ expected_value_after_useful_life=expected_value_after_useful_life,
+ company="_Test Company with perpetual inventory",
+ submit=1,
+ )
+
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
+ # Create and submit Asset Captitalization
+ asset_capitalization = create_asset_capitalization(
+ entry_type="Decapitalization",
+ posting_date=capitalization_date, # half a year
+ target_item_code="Capitalization Target Stock Item",
+ target_qty=target_qty,
+ consumed_asset=consumed_asset.name,
+ company="_Test Company with perpetual inventory",
+ submit=1,
+ )
+
+ # Test Asset Capitalization values
+ self.assertEqual(asset_capitalization.entry_type, "Decapitalization")
+
+ self.assertEqual(
+ asset_capitalization.asset_items[0].current_asset_value, consumed_asset_current_value
+ )
+ self.assertEqual(
+ asset_capitalization.asset_items[0].asset_value, consumed_asset_value_before_disposal
+ )
+ self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value_before_disposal)
+
+ self.assertEqual(asset_capitalization.total_value, consumed_asset_value_before_disposal)
+ self.assertEqual(asset_capitalization.target_incoming_rate, target_incoming_rate)
+
+ # Test Consumed Asset values
+ consumed_asset.reload()
+ self.assertEqual(consumed_asset.status, "Decapitalized")
+
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
+
+ depr_schedule_of_consumed_asset = second_asset_depr_schedule.get("depreciation_schedule")
+
+ consumed_depreciation_schedule = [
+ d
+ for d in depr_schedule_of_consumed_asset
+ if getdate(d.schedule_date) == getdate(capitalization_date)
+ ]
+ self.assertTrue(
+ consumed_depreciation_schedule and consumed_depreciation_schedule[0].journal_entry
+ )
+ self.assertEqual(
+ consumed_depreciation_schedule[0].depreciation_amount, depreciation_before_disposal_amount
+ )
+
+ # Test General Ledger Entries
+ expected_gle = {
+ "_Test Warehouse - TCP1": consumed_asset_value_before_disposal,
+ "_Test Accumulated Depreciations - TCP1": accumulated_depreciation,
+ "_Test Fixed Asset - TCP1": -consumed_asset_purchase_value,
+ }
+ actual_gle = get_actual_gle_dict(asset_capitalization.name)
+ self.assertEqual(actual_gle, expected_gle)
+
+ # Cancel Asset Capitalization and make test entries and status are reversed
+ asset_capitalization.reload()
+ asset_capitalization.cancel()
+ self.assertEqual(consumed_asset.db_get("status"), "Partially Depreciated")
+ self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
+ self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
+
+
+def create_asset_capitalization_data():
+ create_item(
+ "Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0
+ )
+ create_item(
+ "Capitalization Source Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0
+ )
+ create_item(
+ "Capitalization Source Service Item", is_stock_item=0, is_fixed_asset=0, is_purchase_item=0
+ )
+
+
+def create_asset_capitalization(**args):
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ args = frappe._dict(args)
+
+ now = now_datetime()
+ target_asset = frappe.get_doc("Asset", args.target_asset) if args.target_asset else frappe._dict()
+ target_item_code = target_asset.item_code or args.target_item_code
+ company = target_asset.company or args.company or "_Test Company"
+ warehouse = args.warehouse or create_warehouse("_Test Warehouse", company=company)
+ target_warehouse = args.target_warehouse or warehouse
+ source_warehouse = args.source_warehouse or warehouse
+
+ asset_capitalization = frappe.new_doc("Asset Capitalization")
+ asset_capitalization.update(
+ {
+ "entry_type": args.entry_type or "Capitalization",
+ "company": company,
+ "posting_date": args.posting_date or now.strftime("%Y-%m-%d"),
+ "posting_time": args.posting_time or now.strftime("%H:%M:%S.%f"),
+ "target_item_code": target_item_code,
+ "target_asset": target_asset.name,
+ "target_warehouse": target_warehouse,
+ "target_qty": flt(args.target_qty) or 1,
+ "target_batch_no": args.target_batch_no,
+ "target_serial_no": args.target_serial_no,
+ "finance_book": args.finance_book,
+ }
+ )
+
+ if args.posting_date or args.posting_time:
+ asset_capitalization.set_posting_time = 1
+
+ if flt(args.stock_rate):
+ asset_capitalization.append(
+ "stock_items",
+ {
+ "item_code": args.stock_item or "Capitalization Source Stock Item",
+ "warehouse": source_warehouse,
+ "stock_qty": flt(args.stock_qty) or 1,
+ "batch_no": args.stock_batch_no,
+ "serial_no": args.stock_serial_no,
+ },
+ )
+
+ if args.consumed_asset:
+ asset_capitalization.append(
+ "asset_items",
+ {
+ "asset": args.consumed_asset,
+ },
+ )
+
+ if flt(args.service_rate):
+ asset_capitalization.append(
+ "service_items",
+ {
+ "item_code": args.service_item or "Capitalization Source Service Item",
+ "expense_account": args.service_expense_account,
+ "qty": flt(args.service_qty) or 1,
+ "rate": flt(args.service_rate),
+ },
+ )
+
+ if args.submit:
+ create_stock_reconciliation(asset_capitalization, stock_rate=args.stock_rate)
+
+ asset_capitalization.insert()
+
+ if args.submit:
+ asset_capitalization.submit()
+
+ return asset_capitalization
+
+
+def create_stock_reconciliation(asset_capitalization, stock_rate=0):
+ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ EmptyStockReconciliationItemsError,
+ create_stock_reconciliation,
+ )
+
+ if not asset_capitalization.get("stock_items"):
+ return
+
+ try:
+ create_stock_reconciliation(
+ item_code=asset_capitalization.stock_items[0].item_code,
+ warehouse=asset_capitalization.stock_items[0].warehouse,
+ qty=flt(asset_capitalization.stock_items[0].stock_qty),
+ rate=flt(stock_rate),
+ company=asset_capitalization.company,
+ )
+ except EmptyStockReconciliationItemsError:
+ pass
+
+
+def create_depreciation_asset(**args):
+ args = frappe._dict(args)
+
+ asset = frappe.new_doc("Asset")
+ asset.is_existing_asset = 1
+ asset.calculate_depreciation = 1
+ asset.asset_owner = "Company"
+
+ asset.company = args.company or "_Test Company"
+ asset.item_code = args.item_code or "Macbook Pro"
+ asset.asset_name = args.asset_name or asset.item_code
+ asset.location = args.location or "Test Location"
+
+ asset.purchase_date = args.purchase_date or "2020-01-01"
+ asset.available_for_use_date = args.available_for_use_date or asset.purchase_date
+
+ asset.gross_purchase_amount = args.asset_value or 100000
+ asset.purchase_receipt_amount = asset.gross_purchase_amount
+
+ finance_book = asset.append("finance_books")
+ finance_book.depreciation_start_date = args.depreciation_start_date or "2020-12-31"
+ finance_book.depreciation_method = args.depreciation_method or "Straight Line"
+ finance_book.total_number_of_depreciations = cint(args.total_number_of_depreciations) or 3
+ finance_book.frequency_of_depreciation = cint(args.frequency_of_depreciation) or 12
+ finance_book.expected_value_after_useful_life = flt(args.expected_value_after_useful_life)
+
+ if args.submit:
+ asset.submit()
+
+ frappe.db.set_value("Company", "_Test Company", "series_for_depreciation_entry", "DEPR-")
+ post_depreciation_entries(date=finance_book.depreciation_start_date)
+ asset.load_from_db()
+
+ return asset
+
+
+def get_actual_gle_dict(name):
+ return dict(
+ frappe.db.sql(
+ """
+ select account, sum(debit-credit) as diff
+ from `tabGL Entry`
+ where voucher_type = 'Asset Capitalization' and voucher_no = %s
+ group by account
+ having diff != 0
+ """,
+ name,
+ )
+ )
+
+
+def get_actual_sle_dict(name):
+ sles = frappe.db.sql(
+ """
+ select
+ item_code, warehouse,
+ sum(actual_qty) as actual_qty,
+ sum(stock_value_difference) as stock_value_difference
+ from `tabStock Ledger Entry`
+ where voucher_type = 'Asset Capitalization' and voucher_no = %s
+ group by item_code, warehouse
+ having actual_qty != 0
+ """,
+ name,
+ as_dict=1,
+ )
+
+ sle_dict = {}
+ for d in sles:
+ sle_dict[(d.item_code, d.warehouse)] = {
+ "actual_qty": d.actual_qty,
+ "stock_value_difference": d.stock_value_difference,
+ }
+
+ return sle_dict
diff --git a/erpnext/regional/doctype/product_tax_category/__init__.py b/erpnext/assets/doctype/asset_capitalization_asset_item/__init__.py
similarity index 100%
rename from erpnext/regional/doctype/product_tax_category/__init__.py
rename to erpnext/assets/doctype/asset_capitalization_asset_item/__init__.py
diff --git a/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json
new file mode 100644
index 0000000000..ebaaffbad1
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json
@@ -0,0 +1,128 @@
+{
+ "actions": [],
+ "creation": "2021-09-05 15:52:10.124538",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "asset",
+ "asset_name",
+ "finance_book",
+ "column_break_3",
+ "item_code",
+ "item_name",
+ "section_break_6",
+ "current_asset_value",
+ "asset_value",
+ "column_break_9",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break",
+ "fixed_asset_account"
+ ],
+ "fields": [
+ {
+ "fieldname": "asset",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Asset",
+ "options": "Asset",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "asset.asset_name",
+ "fieldname": "asset_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Asset Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "asset.item_code",
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break",
+ "label": "Value"
+ },
+ {
+ "default": "0",
+ "fieldname": "asset_value",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Asset Value",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "fixed_asset_account",
+ "fieldtype": "Link",
+ "label": "Fixed Asset Account",
+ "options": "Account",
+ "read_only": 1
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "finance_book",
+ "fieldtype": "Link",
+ "label": "Finance Book",
+ "options": "Finance Book"
+ },
+ {
+ "fieldname": "current_asset_value",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Current Asset Value",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-09-12 14:30:02.915132",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Asset Capitalization Asset Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/product_tax_category/product_tax_category.py b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py
similarity index 80%
rename from erpnext/regional/doctype/product_tax_category/product_tax_category.py
rename to erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py
index b6be9e0920..ba356d6b9f 100644
--- a/erpnext/regional/doctype/product_tax_category/product_tax_category.py
+++ b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py
@@ -5,5 +5,5 @@
from frappe.model.document import Document
-class ProductTaxCategory(Document):
+class AssetCapitalizationAssetItem(Document):
pass
diff --git a/erpnext/regional/print_format/ksa_pos_invoice/__init__.py b/erpnext/assets/doctype/asset_capitalization_service_item/__init__.py
similarity index 100%
rename from erpnext/regional/print_format/ksa_pos_invoice/__init__.py
rename to erpnext/assets/doctype/asset_capitalization_service_item/__init__.py
diff --git a/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json
new file mode 100644
index 0000000000..0ae1c1428e
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json
@@ -0,0 +1,122 @@
+{
+ "actions": [],
+ "creation": "2021-09-06 13:32:08.642060",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "item_name",
+ "column_break_3",
+ "expense_account",
+ "section_break_6",
+ "qty",
+ "uom",
+ "column_break_9",
+ "rate",
+ "amount",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break"
+ ],
+ "fields": [
+ {
+ "bold": 1,
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item"
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "expense_account",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Expense Account",
+ "options": "Account",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break",
+ "label": "Qty and Rate"
+ },
+ {
+ "columns": 1,
+ "default": "1",
+ "fieldname": "qty",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Qty",
+ "non_negative": 1
+ },
+ {
+ "columns": 1,
+ "fetch_from": "stock_item_code.stock_uom",
+ "fieldname": "uom",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "UOM",
+ "options": "UOM"
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "rate",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Rate",
+ "options": "Company:company:default_currency"
+ },
+ {
+ "default": "0",
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-09-08 15:52:08.598100",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Asset Capitalization Service Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py
new file mode 100644
index 0000000000..28d018ee39
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class AssetCapitalizationServiceItem(Document):
+ pass
diff --git a/erpnext/regional/print_format/ksa_vat_invoice/__init__.py b/erpnext/assets/doctype/asset_capitalization_stock_item/__init__.py
similarity index 100%
rename from erpnext/regional/print_format/ksa_vat_invoice/__init__.py
rename to erpnext/assets/doctype/asset_capitalization_stock_item/__init__.py
diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json
new file mode 100644
index 0000000000..14eb0f6ef2
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json
@@ -0,0 +1,156 @@
+{
+ "actions": [],
+ "creation": "2021-09-05 15:23:23.492310",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "item_name",
+ "column_break_3",
+ "warehouse",
+ "section_break_6",
+ "stock_qty",
+ "stock_uom",
+ "actual_qty",
+ "column_break_9",
+ "valuation_rate",
+ "amount",
+ "batch_and_serial_no_section",
+ "batch_no",
+ "column_break_13",
+ "serial_no",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break"
+ ],
+ "fields": [
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Warehouse",
+ "options": "Warehouse",
+ "reqd": 1
+ },
+ {
+ "fieldname": "batch_no",
+ "fieldtype": "Link",
+ "label": "Batch No",
+ "options": "Batch"
+ },
+ {
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break",
+ "label": "Qty and Rate"
+ },
+ {
+ "columns": 1,
+ "fieldname": "stock_qty",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Qty",
+ "non_negative": 1
+ },
+ {
+ "columns": 1,
+ "fetch_from": "stock_item_code.stock_uom",
+ "fieldname": "stock_uom",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Stock UOM",
+ "options": "UOM",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "valuation_rate",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Valuation Rate",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "batch_and_serial_no_section",
+ "fieldtype": "Section Break",
+ "label": "Batch and Serial No"
+ },
+ {
+ "fieldname": "column_break_13",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "serial_no",
+ "fieldtype": "Small Text",
+ "label": "Serial No"
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "actual_qty",
+ "fieldtype": "Float",
+ "label": "Actual Qty in Warehouse",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-09-08 15:56:20.230548",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Asset Capitalization Stock Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py
similarity index 80%
rename from erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py
rename to erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py
index c24aa8ca7d..5d6f98d5cf 100644
--- a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py
+++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py
@@ -5,5 +5,5 @@
from frappe.model.document import Document
-class TaxJarNexus(Document):
+class AssetCapitalizationStockItem(Document):
pass
diff --git a/erpnext/regional/report/ksa_vat/__init__.py b/erpnext/assets/doctype/asset_depreciation_schedule/__init__.py
similarity index 100%
rename from erpnext/regional/report/ksa_vat/__init__.py
rename to erpnext/assets/doctype/asset_depreciation_schedule/__init__.py
diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js
new file mode 100644
index 0000000000..c28b2b3b6a
--- /dev/null
+++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js
@@ -0,0 +1,51 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+frappe.provide("erpnext.asset");
+
+frappe.ui.form.on('Asset Depreciation Schedule', {
+ onload: function(frm) {
+ frm.events.make_schedules_editable(frm);
+ },
+
+ make_schedules_editable: function(frm) {
+ var is_editable = frm.doc.depreciation_method == "Manual" ? true : false;
+
+ frm.toggle_enable("depreciation_schedule", is_editable);
+ frm.fields_dict["depreciation_schedule"].grid.toggle_enable("schedule_date", is_editable);
+ frm.fields_dict["depreciation_schedule"].grid.toggle_enable("depreciation_amount", is_editable);
+ }
+});
+
+frappe.ui.form.on('Depreciation Schedule', {
+ make_depreciation_entry: function(frm, cdt, cdn) {
+ var row = locals[cdt][cdn];
+ if (!row.journal_entry) {
+ frappe.call({
+ method: "erpnext.assets.doctype.asset.depreciation.make_depreciation_entry",
+ args: {
+ "asset_depr_schedule_name": frm.doc.name,
+ "date": row.schedule_date
+ },
+ callback: function(r) {
+ frappe.model.sync(r.message);
+ frm.refresh();
+ }
+ })
+ }
+ },
+
+ depreciation_amount: function(frm, cdt, cdn) {
+ erpnext.asset.set_accumulated_depreciation(frm);
+ }
+});
+
+erpnext.asset.set_accumulated_depreciation = function(frm) {
+ if(frm.doc.depreciation_method != "Manual") return;
+
+ var accumulated_depreciation = flt(frm.doc.opening_accumulated_depreciation);
+ $.each(frm.doc.schedules || [], function(i, row) {
+ accumulated_depreciation += flt(row.depreciation_amount);
+ frappe.model.set_value(row.doctype, row.name,
+ "accumulated_depreciation_amount", accumulated_depreciation);
+ })
+};
diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json
new file mode 100644
index 0000000000..898c482079
--- /dev/null
+++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json
@@ -0,0 +1,202 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "naming_series:",
+ "creation": "2022-10-31 15:03:35.424877",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "asset",
+ "naming_series",
+ "column_break_2",
+ "opening_accumulated_depreciation",
+ "finance_book",
+ "finance_book_id",
+ "depreciation_details_section",
+ "depreciation_method",
+ "total_number_of_depreciations",
+ "rate_of_depreciation",
+ "column_break_8",
+ "frequency_of_depreciation",
+ "expected_value_after_useful_life",
+ "depreciation_schedule_section",
+ "depreciation_schedule",
+ "details_section",
+ "notes",
+ "status",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "asset",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Asset",
+ "options": "Asset",
+ "reqd": 1
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Naming Series",
+ "options": "ACC-ADS-.YYYY.-"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Asset Depreciation Schedule",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "depreciation_details_section",
+ "fieldtype": "Section Break",
+ "label": "Depreciation Details"
+ },
+ {
+ "fieldname": "finance_book",
+ "fieldtype": "Link",
+ "label": "Finance Book",
+ "options": "Finance Book"
+ },
+ {
+ "fieldname": "depreciation_method",
+ "fieldtype": "Select",
+ "label": "Depreciation Method",
+ "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval:doc.depreciation_method == 'Written Down Value'",
+ "description": "In Percentage",
+ "fieldname": "rate_of_depreciation",
+ "fieldtype": "Percent",
+ "label": "Rate of Depreciation",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "total_number_of_depreciations",
+ "fieldname": "total_number_of_depreciations",
+ "fieldtype": "Int",
+ "label": "Total Number of Depreciations",
+ "read_only": 1
+ },
+ {
+ "fieldname": "depreciation_schedule_section",
+ "fieldtype": "Section Break",
+ "label": "Depreciation Schedule"
+ },
+ {
+ "fieldname": "depreciation_schedule",
+ "fieldtype": "Table",
+ "label": "Depreciation Schedule",
+ "options": "Depreciation Schedule"
+ },
+ {
+ "collapsible": 1,
+ "collapsible_depends_on": "notes",
+ "fieldname": "details_section",
+ "fieldtype": "Section Break",
+ "label": "Details"
+ },
+ {
+ "fieldname": "notes",
+ "fieldtype": "Small Text",
+ "label": "Notes",
+ "read_only": 1
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Status",
+ "options": "Draft\nActive\nCancelled",
+ "read_only": 1
+ },
+ {
+ "depends_on": "frequency_of_depreciation",
+ "fieldname": "frequency_of_depreciation",
+ "fieldtype": "Int",
+ "label": "Frequency of Depreciation (Months)",
+ "read_only": 1
+ },
+ {
+ "fieldname": "expected_value_after_useful_life",
+ "fieldtype": "Currency",
+ "label": "Expected Value After Useful Life",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "finance_book_id",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Finance Book Id",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "depends_on": "opening_accumulated_depreciation",
+ "fieldname": "opening_accumulated_depreciation",
+ "fieldtype": "Currency",
+ "label": "Opening Accumulated Depreciation",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-01-16 21:08:21.421260",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Asset Depreciation Schedule",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Quality Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py
new file mode 100644
index 0000000000..6f02662544
--- /dev/null
+++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py
@@ -0,0 +1,475 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import add_days, add_months, cint, flt, get_last_day, is_last_day_of_the_month
+
+
+class AssetDepreciationSchedule(Document):
+ def before_save(self):
+ if not self.finance_book_id:
+ self.prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name(
+ self.asset, self.finance_book
+ )
+
+ def validate(self):
+ self.validate_another_asset_depr_schedule_does_not_exist()
+
+ def validate_another_asset_depr_schedule_does_not_exist(self):
+ finance_book_filter = ["finance_book", "is", "not set"]
+ if self.finance_book:
+ finance_book_filter = ["finance_book", "=", self.finance_book]
+
+ asset_depr_schedule = frappe.db.exists(
+ "Asset Depreciation Schedule",
+ [
+ ["asset", "=", self.asset],
+ finance_book_filter,
+ ["docstatus", "<", 2],
+ ],
+ )
+
+ if asset_depr_schedule and asset_depr_schedule != self.name:
+ if self.finance_book:
+ frappe.throw(
+ _(
+ "Asset Depreciation Schedule {0} for Asset {1} and Finance Book {2} already exists."
+ ).format(asset_depr_schedule, self.asset, self.finance_book)
+ )
+ else:
+ frappe.throw(
+ _("Asset Depreciation Schedule {0} for Asset {1} already exists.").format(
+ asset_depr_schedule, self.asset
+ )
+ )
+
+ def on_submit(self):
+ self.db_set("status", "Active")
+
+ def before_cancel(self):
+ if not self.flags.should_not_cancel_depreciation_entries:
+ self.cancel_depreciation_entries()
+
+ def cancel_depreciation_entries(self):
+ for d in self.get("depreciation_schedule"):
+ if d.journal_entry:
+ frappe.get_doc("Journal Entry", d.journal_entry).cancel()
+
+ def on_cancel(self):
+ self.db_set("status", "Cancelled")
+
+ def prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name(self, asset_name, fb_name):
+ asset_doc = frappe.get_doc("Asset", asset_name)
+
+ finance_book_filter = ["finance_book", "is", "not set"]
+ if fb_name:
+ finance_book_filter = ["finance_book", "=", fb_name]
+
+ asset_finance_book_name = frappe.db.get_value(
+ doctype="Asset Finance Book",
+ filters=[["parent", "=", asset_name], finance_book_filter],
+ )
+ asset_finance_book_doc = frappe.get_doc("Asset Finance Book", asset_finance_book_name)
+
+ self.prepare_draft_asset_depr_schedule_data(asset_doc, asset_finance_book_doc)
+
+ def prepare_draft_asset_depr_schedule_data(
+ self,
+ asset_doc,
+ row,
+ date_of_disposal=None,
+ date_of_return=None,
+ update_asset_finance_book_row=True,
+ ):
+ self.set_draft_asset_depr_schedule_details(asset_doc, row)
+ self.make_depr_schedule(asset_doc, row, date_of_disposal, update_asset_finance_book_row)
+ self.set_accumulated_depreciation(row, date_of_disposal, date_of_return)
+
+ def set_draft_asset_depr_schedule_details(self, asset_doc, row):
+ self.asset = asset_doc.name
+ self.finance_book = row.finance_book
+ self.finance_book_id = row.idx
+ self.opening_accumulated_depreciation = asset_doc.opening_accumulated_depreciation
+ self.depreciation_method = row.depreciation_method
+ self.total_number_of_depreciations = row.total_number_of_depreciations
+ self.frequency_of_depreciation = row.frequency_of_depreciation
+ self.rate_of_depreciation = row.rate_of_depreciation
+ self.expected_value_after_useful_life = row.expected_value_after_useful_life
+ self.status = "Draft"
+
+ def make_depr_schedule(
+ self, asset_doc, row, date_of_disposal, update_asset_finance_book_row=True
+ ):
+ if row.depreciation_method != "Manual" and not self.get("depreciation_schedule"):
+ self.depreciation_schedule = []
+
+ if not asset_doc.available_for_use_date:
+ return
+
+ start = self.clear_depr_schedule()
+
+ self._make_depr_schedule(asset_doc, row, start, date_of_disposal, update_asset_finance_book_row)
+
+ def clear_depr_schedule(self):
+ start = 0
+ num_of_depreciations_completed = 0
+ depr_schedule = []
+
+ for schedule in self.get("depreciation_schedule"):
+ if schedule.journal_entry:
+ num_of_depreciations_completed += 1
+ depr_schedule.append(schedule)
+ else:
+ start = num_of_depreciations_completed
+ break
+
+ self.depreciation_schedule = depr_schedule
+
+ return start
+
+ def _make_depr_schedule(
+ self, asset_doc, row, start, date_of_disposal, update_asset_finance_book_row
+ ):
+ asset_doc.validate_asset_finance_books(row)
+
+ value_after_depreciation = _get_value_after_depreciation_for_making_schedule(asset_doc, row)
+ row.value_after_depreciation = value_after_depreciation
+
+ if update_asset_finance_book_row:
+ row.db_update()
+
+ number_of_pending_depreciations = cint(row.total_number_of_depreciations) - cint(
+ asset_doc.number_of_depreciations_booked
+ )
+
+ has_pro_rata = asset_doc.check_is_pro_rata(row)
+ if has_pro_rata:
+ number_of_pending_depreciations += 1
+
+ skip_row = False
+ should_get_last_day = is_last_day_of_the_month(row.depreciation_start_date)
+
+ for n in range(start, number_of_pending_depreciations):
+ # If depreciation is already completed (for double declining balance)
+ if skip_row:
+ continue
+
+ depreciation_amount = asset_doc.get_depreciation_amount(value_after_depreciation, row)
+
+ if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
+ schedule_date = add_months(
+ row.depreciation_start_date, n * cint(row.frequency_of_depreciation)
+ )
+
+ if should_get_last_day:
+ schedule_date = get_last_day(schedule_date)
+
+ # schedule date will be a year later from start date
+ # so monthly schedule date is calculated by removing 11 months from it
+ monthly_schedule_date = add_months(schedule_date, -row.frequency_of_depreciation + 1)
+
+ # if asset is being sold or scrapped
+ if date_of_disposal:
+ from_date = asset_doc.available_for_use_date
+ if self.depreciation_schedule:
+ from_date = self.depreciation_schedule[-1].schedule_date
+
+ depreciation_amount, days, months = asset_doc.get_pro_rata_amt(
+ row, depreciation_amount, from_date, date_of_disposal
+ )
+
+ if depreciation_amount > 0:
+ self.add_depr_schedule_row(
+ date_of_disposal,
+ depreciation_amount,
+ row.depreciation_method,
+ )
+
+ break
+
+ # For first row
+ if has_pro_rata and not asset_doc.opening_accumulated_depreciation and n == 0:
+ from_date = add_days(
+ asset_doc.available_for_use_date, -1
+ ) # needed to calc depr amount for available_for_use_date too
+ depreciation_amount, days, months = asset_doc.get_pro_rata_amt(
+ row, depreciation_amount, from_date, row.depreciation_start_date
+ )
+
+ # For first depr schedule date will be the start date
+ # so monthly schedule date is calculated by removing
+ # month difference between use date and start date
+ monthly_schedule_date = add_months(row.depreciation_start_date, -months + 1)
+
+ # For last row
+ elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
+ if not asset_doc.flags.increase_in_asset_life:
+ # In case of increase_in_asset_life, the asset.to_date is already set on asset_repair submission
+ asset_doc.to_date = add_months(
+ asset_doc.available_for_use_date,
+ (n + asset_doc.number_of_depreciations_booked) * cint(row.frequency_of_depreciation),
+ )
+
+ depreciation_amount_without_pro_rata = depreciation_amount
+
+ depreciation_amount, days, months = asset_doc.get_pro_rata_amt(
+ row, depreciation_amount, schedule_date, asset_doc.to_date
+ )
+
+ depreciation_amount = self.get_adjusted_depreciation_amount(
+ depreciation_amount_without_pro_rata, depreciation_amount
+ )
+
+ monthly_schedule_date = add_months(schedule_date, 1)
+ schedule_date = add_days(schedule_date, days)
+ last_schedule_date = schedule_date
+
+ if not depreciation_amount:
+ continue
+ value_after_depreciation -= flt(
+ depreciation_amount, asset_doc.precision("gross_purchase_amount")
+ )
+
+ # Adjust depreciation amount in the last period based on the expected value after useful life
+ if row.expected_value_after_useful_life and (
+ (
+ n == cint(number_of_pending_depreciations) - 1
+ and value_after_depreciation != row.expected_value_after_useful_life
+ )
+ or value_after_depreciation < row.expected_value_after_useful_life
+ ):
+ depreciation_amount += value_after_depreciation - row.expected_value_after_useful_life
+ skip_row = True
+
+ if depreciation_amount > 0:
+ self.add_depr_schedule_row(
+ schedule_date,
+ depreciation_amount,
+ row.depreciation_method,
+ )
+
+ # to ensure that final accumulated depreciation amount is accurate
+ def get_adjusted_depreciation_amount(
+ self, depreciation_amount_without_pro_rata, depreciation_amount_for_last_row
+ ):
+ if not self.opening_accumulated_depreciation:
+ depreciation_amount_for_first_row = self.get_depreciation_amount_for_first_row()
+
+ if (
+ depreciation_amount_for_first_row + depreciation_amount_for_last_row
+ != depreciation_amount_without_pro_rata
+ ):
+ depreciation_amount_for_last_row = (
+ depreciation_amount_without_pro_rata - depreciation_amount_for_first_row
+ )
+
+ return depreciation_amount_for_last_row
+
+ def get_depreciation_amount_for_first_row(self):
+ return self.get("depreciation_schedule")[0].depreciation_amount
+
+ def add_depr_schedule_row(
+ self,
+ schedule_date,
+ depreciation_amount,
+ depreciation_method,
+ ):
+ self.append(
+ "depreciation_schedule",
+ {
+ "schedule_date": schedule_date,
+ "depreciation_amount": depreciation_amount,
+ "depreciation_method": depreciation_method,
+ },
+ )
+
+ def set_accumulated_depreciation(
+ self,
+ row,
+ date_of_disposal=None,
+ date_of_return=None,
+ ignore_booked_entry=False,
+ ):
+ straight_line_idx = [
+ d.idx for d in self.get("depreciation_schedule") if d.depreciation_method == "Straight Line"
+ ]
+
+ accumulated_depreciation = flt(self.opening_accumulated_depreciation)
+ value_after_depreciation = flt(row.value_after_depreciation)
+
+ for i, d in enumerate(self.get("depreciation_schedule")):
+ if ignore_booked_entry and d.journal_entry:
+ continue
+
+ depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount"))
+ value_after_depreciation -= flt(depreciation_amount)
+
+ # for the last row, if depreciation method = Straight Line
+ if (
+ straight_line_idx
+ and i == max(straight_line_idx) - 1
+ and not date_of_disposal
+ and not date_of_return
+ ):
+ depreciation_amount += flt(
+ value_after_depreciation - flt(row.expected_value_after_useful_life),
+ d.precision("depreciation_amount"),
+ )
+
+ d.depreciation_amount = depreciation_amount
+ accumulated_depreciation += d.depreciation_amount
+ d.accumulated_depreciation_amount = flt(
+ accumulated_depreciation, d.precision("accumulated_depreciation_amount")
+ )
+
+
+def _get_value_after_depreciation_for_making_schedule(asset_doc, fb_row):
+ if asset_doc.docstatus == 1 and fb_row.value_after_depreciation:
+ value_after_depreciation = flt(fb_row.value_after_depreciation)
+ else:
+ value_after_depreciation = flt(asset_doc.gross_purchase_amount) - flt(
+ asset_doc.opening_accumulated_depreciation
+ )
+
+ return value_after_depreciation
+
+
+def make_draft_asset_depr_schedules_if_not_present(asset_doc):
+ for row in asset_doc.get("finance_books"):
+ draft_asset_depr_schedule_name = get_asset_depr_schedule_name(
+ asset_doc.name, "Draft", row.finance_book
+ )
+
+ active_asset_depr_schedule_name = get_asset_depr_schedule_name(
+ asset_doc.name, "Active", row.finance_book
+ )
+
+ if not draft_asset_depr_schedule_name and not active_asset_depr_schedule_name:
+ make_draft_asset_depr_schedule(asset_doc, row)
+
+
+def make_draft_asset_depr_schedules(asset_doc):
+ for row in asset_doc.get("finance_books"):
+ make_draft_asset_depr_schedule(asset_doc, row)
+
+
+def make_draft_asset_depr_schedule(asset_doc, row):
+ asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule")
+
+ asset_depr_schedule_doc.prepare_draft_asset_depr_schedule_data(asset_doc, row)
+
+ asset_depr_schedule_doc.insert()
+
+
+def update_draft_asset_depr_schedules(asset_doc):
+ for row in asset_doc.get("finance_books"):
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Draft", row.finance_book)
+
+ if not asset_depr_schedule_doc:
+ continue
+
+ asset_depr_schedule_doc.prepare_draft_asset_depr_schedule_data(asset_doc, row)
+
+ asset_depr_schedule_doc.save()
+
+
+def convert_draft_asset_depr_schedules_into_active(asset_doc):
+ for row in asset_doc.get("finance_books"):
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Draft", row.finance_book)
+
+ if not asset_depr_schedule_doc:
+ continue
+
+ asset_depr_schedule_doc.submit()
+
+
+def cancel_asset_depr_schedules(asset_doc):
+ for row in asset_doc.get("finance_books"):
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Active", row.finance_book)
+
+ if not asset_depr_schedule_doc:
+ continue
+
+ asset_depr_schedule_doc.cancel()
+
+
+def make_new_active_asset_depr_schedules_and_cancel_current_ones(
+ asset_doc, notes, date_of_disposal=None, date_of_return=None
+):
+ for row in asset_doc.get("finance_books"):
+ current_asset_depr_schedule_doc = get_asset_depr_schedule_doc(
+ asset_doc.name, "Active", row.finance_book
+ )
+
+ if not current_asset_depr_schedule_doc:
+ frappe.throw(
+ _("Asset Depreciation Schedule not found for Asset {0} and Finance Book {1}").format(
+ asset_doc.name, row.finance_book
+ )
+ )
+
+ new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc)
+
+ new_asset_depr_schedule_doc.make_depr_schedule(asset_doc, row, date_of_disposal)
+ new_asset_depr_schedule_doc.set_accumulated_depreciation(row, date_of_disposal, date_of_return)
+
+ new_asset_depr_schedule_doc.notes = notes
+
+ current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True
+ current_asset_depr_schedule_doc.cancel()
+
+ new_asset_depr_schedule_doc.submit()
+
+
+def get_temp_asset_depr_schedule_doc(
+ asset_doc, row, date_of_disposal=None, date_of_return=None, update_asset_finance_book_row=False
+):
+ asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule")
+
+ asset_depr_schedule_doc.prepare_draft_asset_depr_schedule_data(
+ asset_doc,
+ row,
+ date_of_disposal,
+ date_of_return,
+ update_asset_finance_book_row,
+ )
+
+ return asset_depr_schedule_doc
+
+
+@frappe.whitelist()
+def get_depr_schedule(asset_name, status, finance_book=None):
+ asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_name, status, finance_book)
+
+ if not asset_depr_schedule_doc:
+ return
+
+ return asset_depr_schedule_doc.get("depreciation_schedule")
+
+
+def get_asset_depr_schedule_doc(asset_name, status, finance_book=None):
+ asset_depr_schedule_name = get_asset_depr_schedule_name(asset_name, status, finance_book)
+
+ if not asset_depr_schedule_name:
+ return
+
+ asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
+
+ return asset_depr_schedule_doc
+
+
+def get_asset_depr_schedule_name(asset_name, status, finance_book=None):
+ finance_book_filter = ["finance_book", "is", "not set"]
+ if finance_book:
+ finance_book_filter = ["finance_book", "=", finance_book]
+
+ return frappe.db.get_value(
+ doctype="Asset Depreciation Schedule",
+ filters=[
+ ["asset", "=", asset_name],
+ finance_book_filter,
+ ["status", "=", status],
+ ],
+ )
diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py
new file mode 100644
index 0000000000..024121d394
--- /dev/null
+++ b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+)
+
+
+class TestAssetDepreciationSchedule(FrappeTestCase):
+ def setUp(self):
+ create_asset_data()
+
+ def test_throw_error_if_another_asset_depr_schedule_exist(self):
+ asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, submit=1)
+
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
+ second_asset_depr_schedule = frappe.get_doc(
+ {"doctype": "Asset Depreciation Schedule", "asset": asset.name, "finance_book": None}
+ )
+
+ self.assertRaises(frappe.ValidationError, second_asset_depr_schedule.insert)
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js
index f5e4e723b4..f9ed2cc344 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.js
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.js
@@ -76,7 +76,7 @@ frappe.ui.form.on('Asset Repair Consumed Item', {
'warehouse': frm.doc.warehouse,
'qty': item.consumed_quantity,
'serial_no': item.serial_no,
- 'company': frm.doc.company
+ 'company': frm.doc.company,
};
frappe.call({
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json
index ba3189887c..accb5bf54b 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.json
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.json
@@ -238,7 +238,6 @@
"no_copy": 1
},
{
- "depends_on": "eval:!doc.__islocal",
"fieldname": "purchase_invoice",
"fieldtype": "Link",
"label": "Purchase Invoice",
@@ -257,6 +256,7 @@
"fieldname": "stock_entry",
"fieldtype": "Link",
"label": "Stock Entry",
+ "no_copy": 1,
"options": "Stock Entry",
"read_only": 1
}
@@ -264,10 +264,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-06-25 13:14:38.307723",
+ "modified": "2022-08-16 15:55:25.023471",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -303,6 +304,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "asset_name",
"track_changes": 1,
"track_seen": 1
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py
index 5bf6011cf8..a7172a72c6 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.py
@@ -1,13 +1,17 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
import frappe
from frappe import _
-from frappe.utils import add_months, cint, flt, getdate, time_diff_in_hours
+from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours
+import erpnext
from erpnext.accounts.general_ledger import make_gl_entries
from erpnext.assets.doctype.asset.asset import get_asset_account
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_depr_schedule,
+ make_new_active_asset_depr_schedules_and_cancel_current_ones,
+)
from erpnext.controllers.accounts_controller import AccountsController
@@ -17,7 +21,7 @@ class AssetRepair(AccountsController):
self.update_status()
if self.get("stock_items"):
- self.set_total_value()
+ self.set_stock_items_cost()
self.calculate_total_repair_cost()
def update_status(self):
@@ -26,7 +30,7 @@ class AssetRepair(AccountsController):
else:
self.asset_doc.set_status()
- def set_total_value(self):
+ def set_stock_items_cost(self):
for item in self.get("stock_items"):
item.total_value = flt(item.valuation_rate) * flt(item.consumed_quantity)
@@ -52,8 +56,14 @@ class AssetRepair(AccountsController):
):
self.modify_depreciation_schedule()
+ notes = _(
+ "This schedule was created when Asset {0} was repaired through Asset Repair {1}."
+ ).format(
+ get_link_to_form(self.asset_doc.doctype, self.asset_doc.name),
+ get_link_to_form(self.doctype, self.name),
+ )
self.asset_doc.flags.ignore_validate_update_after_submit = True
- self.asset_doc.prepare_depreciation_data()
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes)
self.asset_doc.save()
def before_cancel(self):
@@ -66,16 +76,24 @@ class AssetRepair(AccountsController):
if self.get("capitalize_repair_cost"):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
self.make_gl_entries(cancel=True)
+ self.db_set("stock_entry", None)
if (
frappe.db.get_value("Asset", self.asset, "calculate_depreciation")
and self.increase_in_asset_life
):
self.revert_depreciation_schedule_on_cancellation()
+ notes = _("This schedule was created when Asset {0}'s Asset Repair {1} was cancelled.").format(
+ get_link_to_form(self.asset_doc.doctype, self.asset_doc.name),
+ get_link_to_form(self.doctype, self.name),
+ )
self.asset_doc.flags.ignore_validate_update_after_submit = True
- self.asset_doc.prepare_depreciation_data()
+ make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes)
self.asset_doc.save()
+ def after_delete(self):
+ frappe.get_doc("Asset", self.asset).set_status()
+
def check_repair_status(self):
if self.repair_status == "Pending":
frappe.throw(_("Please update Repair Status."))
@@ -133,6 +151,8 @@ class AssetRepair(AccountsController):
"qty": stock_item.consumed_quantity,
"basic_rate": stock_item.valuation_rate,
"serial_no": stock_item.serial_no,
+ "cost_center": self.cost_center,
+ "project": self.project,
},
)
@@ -142,72 +162,42 @@ class AssetRepair(AccountsController):
self.db_set("stock_entry", stock_entry.name)
def increase_stock_quantity(self):
- stock_entry = frappe.get_doc("Stock Entry", self.stock_entry)
- stock_entry.flags.ignore_links = True
- stock_entry.cancel()
+ if self.stock_entry:
+ stock_entry = frappe.get_doc("Stock Entry", self.stock_entry)
+ stock_entry.flags.ignore_links = True
+ stock_entry.cancel()
def make_gl_entries(self, cancel=False):
- if flt(self.repair_cost) > 0:
+ if flt(self.total_repair_cost) > 0:
gl_entries = self.get_gl_entries()
make_gl_entries(gl_entries, cancel)
def get_gl_entries(self):
gl_entries = []
- repair_and_maintenance_account = frappe.db.get_value(
- "Company", self.company, "repair_and_maintenance_account"
- )
+
fixed_asset_account = get_asset_account(
"fixed_asset_account", asset=self.asset, company=self.company
)
- expense_account = (
+ self.get_gl_entries_for_repair_cost(gl_entries, fixed_asset_account)
+ self.get_gl_entries_for_consumed_items(gl_entries, fixed_asset_account)
+
+ return gl_entries
+
+ def get_gl_entries_for_repair_cost(self, gl_entries, fixed_asset_account):
+ if flt(self.repair_cost) <= 0:
+ return
+
+ pi_expense_account = (
frappe.get_doc("Purchase Invoice", self.purchase_invoice).items[0].expense_account
)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "credit": self.repair_cost,
- "credit_in_account_currency": self.repair_cost,
- "against": repair_and_maintenance_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": getdate(),
- "company": self.company,
- },
- item=self,
- )
- )
-
- if self.get("stock_consumption"):
- # creating GL Entries for each row in Stock Items based on the Stock Entry created for it
- stock_entry = frappe.get_doc("Stock Entry", self.stock_entry)
- for item in stock_entry.items:
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account,
- "credit": item.amount,
- "credit_in_account_currency": item.amount,
- "against": repair_and_maintenance_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": getdate(),
- "company": self.company,
- },
- item=self,
- )
- )
-
gl_entries.append(
self.get_gl_dict(
{
"account": fixed_asset_account,
- "debit": self.total_repair_cost,
- "debit_in_account_currency": self.total_repair_cost,
- "against": expense_account,
+ "debit": self.repair_cost,
+ "debit_in_account_currency": self.repair_cost,
+ "against": pi_expense_account,
"voucher_type": self.doctype,
"voucher_no": self.name,
"cost_center": self.cost_center,
@@ -220,7 +210,75 @@ class AssetRepair(AccountsController):
)
)
- return gl_entries
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": pi_expense_account,
+ "credit": self.repair_cost,
+ "credit_in_account_currency": self.repair_cost,
+ "against": fixed_asset_account,
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "cost_center": self.cost_center,
+ "posting_date": getdate(),
+ "company": self.company,
+ },
+ item=self,
+ )
+ )
+
+ def get_gl_entries_for_consumed_items(self, gl_entries, fixed_asset_account):
+ if not (self.get("stock_consumption") and self.get("stock_items")):
+ return
+
+ # creating GL Entries for each row in Stock Items based on the Stock Entry created for it
+ stock_entry = frappe.get_doc("Stock Entry", self.stock_entry)
+
+ default_expense_account = None
+ if not erpnext.is_perpetual_inventory_enabled(self.company):
+ default_expense_account = frappe.get_cached_value(
+ "Company", self.company, "default_expense_account"
+ )
+ if not default_expense_account:
+ frappe.throw(_("Please set default Expense Account in Company {0}").format(self.company))
+
+ for item in stock_entry.items:
+ if flt(item.amount) > 0:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account or default_expense_account,
+ "credit": item.amount,
+ "credit_in_account_currency": item.amount,
+ "against": fixed_asset_account,
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "cost_center": self.cost_center,
+ "posting_date": getdate(),
+ "company": self.company,
+ },
+ item=self,
+ )
+ )
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": fixed_asset_account,
+ "debit": item.amount,
+ "debit_in_account_currency": item.amount,
+ "against": item.expense_account or default_expense_account,
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "cost_center": self.cost_center,
+ "posting_date": getdate(),
+ "against_voucher_type": "Stock Entry",
+ "against_voucher": self.stock_entry,
+ "company": self.company,
+ },
+ item=self,
+ )
+ )
def modify_depreciation_schedule(self):
for row in self.asset_doc.finance_books:
@@ -238,8 +296,10 @@ class AssetRepair(AccountsController):
asset.number_of_depreciations_booked
)
+ depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book)
+
# the Schedule Date in the final row of the old Depreciation Schedule
- last_schedule_date = asset.schedules[len(asset.schedules) - 1].schedule_date
+ last_schedule_date = depr_schedule[len(depr_schedule) - 1].schedule_date
# the Schedule Date in the final row of the new Depreciation Schedule
asset.to_date = add_months(last_schedule_date, extra_months)
@@ -269,8 +329,10 @@ class AssetRepair(AccountsController):
asset.number_of_depreciations_booked
)
+ depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book)
+
# the Schedule Date in the final row of the modified Depreciation Schedule
- last_schedule_date = asset.schedules[len(asset.schedules) - 1].schedule_date
+ last_schedule_date = depr_schedule[len(depr_schedule) - 1].schedule_date
# the Schedule Date in the final row of the original Depreciation Schedule
asset.to_date = add_months(last_schedule_date, -extra_months)
diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
index 4e7cf78090..a9d0b25755 100644
--- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
@@ -6,11 +6,18 @@ import unittest
import frappe
from frappe.utils import flt, nowdate
+from erpnext.assets.doctype.asset.asset import (
+ get_asset_account,
+ get_asset_value_after_depreciation,
+)
from erpnext.assets.doctype.asset.test_asset import (
create_asset,
create_asset_data,
set_depreciation_settings_in_company,
)
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+)
from erpnext.stock.doctype.item.test_item import create_item
@@ -105,48 +112,153 @@ class TestAssetRepair(unittest.TestCase):
def test_increase_in_asset_value_due_to_stock_consumption(self):
asset = create_asset(calculate_depreciation=1, submit=1)
- initial_asset_value = get_asset_value(asset)
+ initial_asset_value = get_asset_value_after_depreciation(asset.name)
asset_repair = create_asset_repair(asset=asset, stock_consumption=1, submit=1)
asset.reload()
- increase_in_asset_value = get_asset_value(asset) - initial_asset_value
+ increase_in_asset_value = get_asset_value_after_depreciation(asset.name) - initial_asset_value
self.assertEqual(asset_repair.stock_items[0].total_value, increase_in_asset_value)
def test_increase_in_asset_value_due_to_repair_cost_capitalisation(self):
asset = create_asset(calculate_depreciation=1, submit=1)
- initial_asset_value = get_asset_value(asset)
+ initial_asset_value = get_asset_value_after_depreciation(asset.name)
asset_repair = create_asset_repair(asset=asset, capitalize_repair_cost=1, submit=1)
asset.reload()
- increase_in_asset_value = get_asset_value(asset) - initial_asset_value
+ increase_in_asset_value = get_asset_value_after_depreciation(asset.name) - initial_asset_value
self.assertEqual(asset_repair.repair_cost, increase_in_asset_value)
def test_purchase_invoice(self):
asset_repair = create_asset_repair(capitalize_repair_cost=1, submit=1)
self.assertTrue(asset_repair.purchase_invoice)
- def test_gl_entries(self):
- asset_repair = create_asset_repair(capitalize_repair_cost=1, submit=1)
- gl_entry = frappe.get_last_doc("GL Entry")
- self.assertEqual(asset_repair.name, gl_entry.voucher_no)
+ def test_gl_entries_with_perpetual_inventory(self):
+ set_depreciation_settings_in_company(company="_Test Company with perpetual inventory")
+
+ asset_category = frappe.get_doc("Asset Category", "Computers")
+ asset_category.append(
+ "accounts",
+ {
+ "company_name": "_Test Company with perpetual inventory",
+ "fixed_asset_account": "_Test Fixed Asset - TCP1",
+ "accumulated_depreciation_account": "_Test Accumulated Depreciations - TCP1",
+ "depreciation_expense_account": "_Test Depreciations - TCP1",
+ },
+ )
+ asset_category.save()
+
+ asset_repair = create_asset_repair(
+ capitalize_repair_cost=1,
+ stock_consumption=1,
+ warehouse="Stores - TCP1",
+ company="_Test Company with perpetual inventory",
+ submit=1,
+ )
+
+ gl_entries = frappe.db.sql(
+ """
+ select
+ account,
+ sum(debit) as debit,
+ sum(credit) as credit
+ from `tabGL Entry`
+ where
+ voucher_type='Asset Repair'
+ and voucher_no=%s
+ group by
+ account
+ """,
+ asset_repair.name,
+ as_dict=1,
+ )
+
+ self.assertTrue(gl_entries)
+
+ fixed_asset_account = get_asset_account(
+ "fixed_asset_account", asset=asset_repair.asset, company=asset_repair.company
+ )
+ pi_expense_account = (
+ frappe.get_doc("Purchase Invoice", asset_repair.purchase_invoice).items[0].expense_account
+ )
+ stock_entry_expense_account = (
+ frappe.get_doc("Stock Entry", asset_repair.stock_entry).get("items")[0].expense_account
+ )
+
+ expected_values = {
+ fixed_asset_account: [asset_repair.total_repair_cost, 0],
+ pi_expense_account: [0, asset_repair.repair_cost],
+ stock_entry_expense_account: [0, 100],
+ }
+
+ for d in gl_entries:
+ self.assertEqual(expected_values[d.account][0], d.debit)
+ self.assertEqual(expected_values[d.account][1], d.credit)
+
+ def test_gl_entries_with_periodical_inventory(self):
+ frappe.db.set_value(
+ "Company", "_Test Company", "default_expense_account", "Cost of Goods Sold - _TC"
+ )
+ asset_repair = create_asset_repair(
+ capitalize_repair_cost=1,
+ stock_consumption=1,
+ submit=1,
+ )
+
+ gl_entries = frappe.db.sql(
+ """
+ select
+ account,
+ sum(debit) as debit,
+ sum(credit) as credit
+ from `tabGL Entry`
+ where
+ voucher_type='Asset Repair'
+ and voucher_no=%s
+ group by
+ account
+ """,
+ asset_repair.name,
+ as_dict=1,
+ )
+
+ self.assertTrue(gl_entries)
+
+ fixed_asset_account = get_asset_account(
+ "fixed_asset_account", asset=asset_repair.asset, company=asset_repair.company
+ )
+ default_expense_account = frappe.get_cached_value(
+ "Company", asset_repair.company, "default_expense_account"
+ )
+
+ expected_values = {fixed_asset_account: [1100, 0], default_expense_account: [0, 1100]}
+
+ for d in gl_entries:
+ self.assertEqual(expected_values[d.account][0], d.debit)
+ self.assertEqual(expected_values[d.account][1], d.credit)
def test_increase_in_asset_life(self):
asset = create_asset(calculate_depreciation=1, submit=1)
+
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
initial_num_of_depreciations = num_of_depreciations(asset)
create_asset_repair(asset=asset, capitalize_repair_cost=1, submit=1)
+
asset.reload()
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
self.assertEqual((initial_num_of_depreciations + 1), num_of_depreciations(asset))
self.assertEqual(
- asset.schedules[-1].accumulated_depreciation_amount,
+ second_asset_depr_schedule.get("depreciation_schedule")[-1].accumulated_depreciation_amount,
asset.finance_books[0].value_after_depreciation,
)
-def get_asset_value(asset):
- return asset.finance_books[0].value_after_depreciation
-
-
def num_of_depreciations(asset):
return asset.finance_books[0].total_number_of_depreciations
@@ -160,7 +272,7 @@ def create_asset_repair(**args):
if args.asset:
asset = args.asset
else:
- asset = create_asset(is_existing_asset=1, submit=1)
+ asset = create_asset(is_existing_asset=1, submit=1, company=args.company)
asset_repair = frappe.new_doc("Asset Repair")
asset_repair.update(
{
@@ -192,7 +304,7 @@ def create_asset_repair(**args):
if args.submit:
asset_repair.repair_status = "Completed"
- asset_repair.cost_center = "_Test Cost Center - _TC"
+ asset_repair.cost_center = frappe.db.get_value("Company", asset.company, "cost_center")
if args.stock_consumption:
stock_entry = frappe.get_doc(
@@ -204,6 +316,8 @@ def create_asset_repair(**args):
"t_warehouse": asset_repair.warehouse,
"item_code": asset_repair.stock_items[0].item_code,
"qty": asset_repair.stock_items[0].consumed_quantity,
+ "basic_rate": args.rate if args.get("rate") is not None else 100,
+ "cost_center": asset_repair.cost_center,
},
)
stock_entry.submit()
@@ -213,7 +327,13 @@ def create_asset_repair(**args):
asset_repair.repair_cost = 1000
if asset.calculate_depreciation:
asset_repair.increase_in_asset_life = 12
- asset_repair.purchase_invoice = make_purchase_invoice().name
+ pi = make_purchase_invoice(
+ company=asset.company,
+ expense_account=frappe.db.get_value("Company", asset.company, "default_expense_account"),
+ cost_center=asset_repair.cost_center,
+ warehouse=asset_repair.warehouse,
+ )
+ asset_repair.purchase_invoice = pi.name
asset_repair.submit()
return asset_repair
diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js
index 36f510b18e..ae0e1bda02 100644
--- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js
+++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js
@@ -47,7 +47,7 @@ frappe.ui.form.on('Asset Value Adjustment', {
set_current_asset_value: function(frm) {
if (frm.doc.asset) {
frm.call({
- method: "erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment.get_current_asset_value",
+ method: "erpnext.assets.doctype.asset.asset.get_asset_value_after_depreciation",
args: {
asset: frm.doc.asset,
finance_book: frm.doc.finance_book
diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
index 59ab6a910d..31d6ffab5f 100644
--- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
+++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
@@ -5,13 +5,16 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, date_diff, flt, formatdate, getdate
+from frappe.utils import date_diff, flt, formatdate, get_link_to_form, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
-from erpnext.assets.doctype.asset.asset import get_depreciation_amount
+from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+)
class AssetValueAdjustment(Document):
@@ -42,7 +45,7 @@ class AssetValueAdjustment(Document):
def set_current_asset_value(self):
if not self.current_asset_value and self.asset:
- self.current_asset_value = get_current_asset_value(self.asset, self.finance_book)
+ self.current_asset_value = get_asset_value_after_depreciation(self.asset, self.finance_book)
def make_depreciation_entry(self):
asset = frappe.get_doc("Asset", self.asset)
@@ -61,7 +64,9 @@ class AssetValueAdjustment(Document):
je.naming_series = depreciation_series
je.posting_date = self.date
je.company = self.company
- je.remark = "Depreciation Entry against {0} worth {1}".format(self.asset, self.difference_amount)
+ je.remark = _("Depreciation Entry against {0} worth {1}").format(
+ self.asset, self.difference_amount
+ )
je.finance_book = self.finance_book
credit_entry = {
@@ -110,27 +115,54 @@ class AssetValueAdjustment(Document):
for d in asset.finance_books:
d.value_after_depreciation = asset_value
+ current_asset_depr_schedule_doc = get_asset_depr_schedule_doc(
+ asset.name, "Active", d.finance_book
+ )
+
+ new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc)
+ new_asset_depr_schedule_doc.status = "Draft"
+ new_asset_depr_schedule_doc.docstatus = 0
+
+ current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True
+ current_asset_depr_schedule_doc.cancel()
+
+ if self.docstatus == 1:
+ notes = _(
+ "This schedule was created when Asset {0} was adjusted through Asset Value Adjustment {1}."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name),
+ get_link_to_form(self.get("doctype"), self.get("name")),
+ )
+ elif self.docstatus == 2:
+ notes = _(
+ "This schedule was created when Asset {0}'s Asset Value Adjustment {1} was cancelled."
+ ).format(
+ get_link_to_form(asset.doctype, asset.name),
+ get_link_to_form(self.get("doctype"), self.get("name")),
+ )
+ new_asset_depr_schedule_doc.notes = notes
+
+ new_asset_depr_schedule_doc.insert()
+
+ depr_schedule = new_asset_depr_schedule_doc.get("depreciation_schedule")
+
if d.depreciation_method in ("Straight Line", "Manual"):
- end_date = max(s.schedule_date for s in asset.schedules if cint(s.finance_book_id) == d.idx)
+ end_date = max(s.schedule_date for s in depr_schedule)
total_days = date_diff(end_date, self.date)
rate_per_day = flt(d.value_after_depreciation) / flt(total_days)
from_date = self.date
else:
- no_of_depreciations = len(
- [
- s.name for s in asset.schedules if (cint(s.finance_book_id) == d.idx and not s.journal_entry)
- ]
- )
+ no_of_depreciations = len([s.name for s in depr_schedule if not s.journal_entry])
value_after_depreciation = d.value_after_depreciation
- for data in asset.schedules:
- if cint(data.finance_book_id) == d.idx and not data.journal_entry:
+ for data in depr_schedule:
+ if not data.journal_entry:
if d.depreciation_method in ("Straight Line", "Manual"):
days = date_diff(data.schedule_date, from_date)
depreciation_amount = days * rate_per_day
from_date = data.schedule_date
else:
- depreciation_amount = get_depreciation_amount(asset, value_after_depreciation, d)
+ depreciation_amount = asset.get_depreciation_amount(value_after_depreciation, d)
if depreciation_amount:
value_after_depreciation -= flt(depreciation_amount)
@@ -138,16 +170,9 @@ class AssetValueAdjustment(Document):
d.db_update()
- asset.set_accumulated_depreciation(ignore_booked_entry=True)
- for asset_data in asset.schedules:
- if not asset_data.journal_entry:
- asset_data.db_update()
+ new_asset_depr_schedule_doc.set_accumulated_depreciation(d, ignore_booked_entry=True)
+ for asset_data in depr_schedule:
+ if not asset_data.journal_entry:
+ asset_data.db_update()
-
-@frappe.whitelist()
-def get_current_asset_value(asset, finance_book=None):
- cond = {"parent": asset, "parenttype": "Asset"}
- if finance_book:
- cond.update({"finance_book": finance_book})
-
- return frappe.db.get_value("Asset Finance Book", cond, "value_after_depreciation")
+ new_asset_depr_schedule_doc.submit()
diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py
index 62c636624c..0b3dcba024 100644
--- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py
+++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py
@@ -6,9 +6,10 @@ import unittest
import frappe
from frappe.utils import add_days, get_last_day, nowdate
+from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.test_asset import create_asset_data
-from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import (
- get_current_asset_value,
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -43,7 +44,7 @@ class TestAssetValueAdjustment(unittest.TestCase):
)
asset_doc.submit()
- current_value = get_current_asset_value(asset_doc.name)
+ current_value = get_asset_value_after_depreciation(asset_doc.name)
self.assertEqual(current_value, 100000.0)
def test_asset_depreciation_value_adjustment(self):
@@ -73,12 +74,21 @@ class TestAssetValueAdjustment(unittest.TestCase):
)
asset_doc.submit()
- current_value = get_current_asset_value(asset_doc.name)
+ first_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Active")
+
+ current_value = get_asset_value_after_depreciation(asset_doc.name)
adj_doc = make_asset_value_adjustment(
asset=asset_doc.name, current_asset_value=current_value, new_asset_value=50000.0
)
adj_doc.submit()
+ first_asset_depr_schedule.load_from_db()
+
+ second_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active")
+ self.assertEquals(second_asset_depr_schedule.status, "Active")
+ self.assertEquals(first_asset_depr_schedule.status, "Cancelled")
+
expected_gle = (
("_Test Accumulated Depreciations - _TC", 0.0, 50000.0),
("_Test Depreciations - _TC", 50000.0, 0.0),
diff --git a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json
index 35a2c9dd7f..882c4bf00b 100644
--- a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json
+++ b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json
@@ -1,318 +1,84 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 1,
- "autoname": "",
- "beta": 0,
- "creation": "2016-03-02 15:11:01.278862",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2016-03-02 15:11:01.278862",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "schedule_date",
+ "depreciation_amount",
+ "column_break_3",
+ "accumulated_depreciation_amount",
+ "journal_entry",
+ "make_depreciation_entry",
+ "depreciation_method"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "finance_book",
- "fieldtype": "Link",
- "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": "Finance Book",
- "length": 0,
- "no_copy": 0,
- "options": "Finance Book",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "schedule_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Schedule Date",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "schedule_date",
- "fieldtype": "Date",
- "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": "Schedule Date",
- "length": 0,
- "no_copy": 1,
- "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,
- "unique": 0
- },
+ "fieldname": "depreciation_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Depreciation Amount",
+ "options": "Company:company:default_currency",
+ "reqd": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "depreciation_amount",
- "fieldtype": "Currency",
- "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": "Depreciation Amount",
- "length": 0,
- "no_copy": 1,
- "options": "Company:company:default_currency",
- "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,
- "unique": 0
- },
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_3",
- "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,
- "unique": 0
- },
+ "fieldname": "accumulated_depreciation_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Accumulated Depreciation Amount",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "accumulated_depreciation_amount",
- "fieldtype": "Currency",
- "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": "Accumulated Depreciation Amount",
- "length": 0,
- "no_copy": 1,
- "options": "Company:company:default_currency",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "depends_on": "eval:doc.docstatus==1",
+ "fieldname": "journal_entry",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Journal Entry",
+ "options": "Journal Entry",
+ "read_only": 1
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "eval:doc.docstatus==1",
- "fieldname": "journal_entry",
- "fieldtype": "Link",
- "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": "Journal Entry",
- "length": 0,
- "no_copy": 1,
- "options": "Journal Entry",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "allow_on_submit": 1,
+ "depends_on": "eval:(doc.docstatus==1 && !doc.journal_entry && doc.schedule_date <= get_today())",
+ "fieldname": "make_depreciation_entry",
+ "fieldtype": "Button",
+ "label": "Make Depreciation Entry"
+ },
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "eval:(doc.docstatus==1 && !doc.journal_entry && doc.schedule_date <= get_today())",
- "fieldname": "make_depreciation_entry",
- "fieldtype": "Button",
- "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": "Make Depreciation Entry",
- "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,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "finance_book_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": "Finance Book Id",
- "length": 0,
- "no_copy": 1,
- "permlevel": 0,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "depreciation_method",
- "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": "Depreciation Method",
- "length": 0,
- "no_copy": 1,
- "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual",
- "permlevel": 0,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "depreciation_method",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Depreciation Method",
+ "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual",
+ "print_hide": 1,
+ "read_only": 1
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "modified": "2018-05-10 15:12:41.679436",
- "modified_by": "Administrator",
- "module": "Assets",
- "name": "Depreciation Schedule",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 0,
- "track_seen": 0
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2022-12-06 20:35:50.264281",
+ "modified_by": "Administrator",
+ "module": "Assets",
+ "name": "Depreciation Schedule",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/assets/doctype/location/location.py b/erpnext/assets/doctype/location/location.py
index 0d87bb2bf4..5bff3dd8c9 100644
--- a/erpnext/assets/doctype/location/location.py
+++ b/erpnext/assets/doctype/location/location.py
@@ -200,11 +200,11 @@ def get_children(doctype, parent=None, location=None, is_root=False):
name as value,
is_group as expandable
from
- `tab{doctype}` comp
+ `tabLocation` comp
where
ifnull(parent_location, "")={parent}
""".format(
- doctype=doctype, parent=frappe.db.escape(parent)
+ parent=frappe.db.escape(parent)
),
as_dict=1,
)
diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py
index 6b14dce084..59d43b1ea6 100644
--- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py
+++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py
@@ -4,6 +4,7 @@
import frappe
from frappe import _
+from frappe.query_builder.functions import Sum
from frappe.utils import cstr, flt, formatdate, getdate
from erpnext.accounts.report.financial_statements import (
@@ -11,6 +12,8 @@ from erpnext.accounts.report.financial_statements import (
get_period_list,
validate_fiscal_year,
)
+from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
+from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts
def execute(filters=None):
@@ -85,7 +88,9 @@ def get_data(filters):
"asset_name",
"status",
"department",
+ "company",
"cost_center",
+ "calculate_depreciation",
"purchase_receipt",
"asset_category",
"purchase_date",
@@ -97,12 +102,21 @@ def get_data(filters):
]
assets_record = frappe.db.get_all("Asset", filters=conditions, fields=fields)
+ assets_linked_to_fb = frappe.db.get_all(
+ doctype="Asset Finance Book",
+ filters={"finance_book": filters.finance_book or ("is", "not set")},
+ pluck="parent",
+ )
+
for asset in assets_record:
- asset_value = (
- asset.gross_purchase_amount
- - flt(asset.opening_accumulated_depreciation)
- - flt(depreciation_amount_map.get(asset.name))
- )
+ if filters.finance_book:
+ if asset.asset_id not in assets_linked_to_fb:
+ continue
+ else:
+ if asset.calculate_depreciation and asset.asset_id not in assets_linked_to_fb:
+ continue
+
+ asset_value = get_asset_value_after_depreciation(asset.asset_id, filters.finance_book)
row = {
"asset_id": asset.asset_id,
"asset_name": asset.asset_name,
@@ -113,7 +127,7 @@ def get_data(filters):
or pi_supplier_map.get(asset.purchase_invoice),
"gross_purchase_amount": asset.gross_purchase_amount,
"opening_accumulated_depreciation": asset.opening_accumulated_depreciation,
- "depreciated_amount": depreciation_amount_map.get(asset.asset_id) or 0.0,
+ "depreciated_amount": get_depreciation_amount_of_asset(asset, depreciation_amount_map, filters),
"available_for_use_date": asset.available_for_use_date,
"location": asset.location,
"asset_category": asset.asset_category,
@@ -137,6 +151,7 @@ def prepare_chart_data(data, filters):
filters.filter_based_on,
"Monthly",
company=filters.company,
+ ignore_fiscal_year=True,
)
for d in period_list:
@@ -170,25 +185,61 @@ def prepare_chart_data(data, filters):
}
+def get_depreciation_amount_of_asset(asset, depreciation_amount_map, filters):
+ if asset.calculate_depreciation:
+ depr_amount = depreciation_amount_map.get(asset.asset_id) or 0.0
+ else:
+ depr_amount = get_manual_depreciation_amount_of_asset(asset, filters)
+
+ return flt(depr_amount, 2)
+
+
def get_finance_book_value_map(filters):
date = filters.to_date if filters.filter_based_on == "Date Range" else filters.year_end_date
return frappe._dict(
frappe.db.sql(
""" Select
- parent, SUM(depreciation_amount)
- FROM `tabDepreciation Schedule`
+ ads.asset, SUM(depreciation_amount)
+ FROM `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds
WHERE
- parentfield='schedules'
- AND schedule_date<=%s
- AND journal_entry IS NOT NULL
- AND ifnull(finance_book, '')=%s
- GROUP BY parent""",
- (date, cstr(filters.finance_book or "")),
+ ds.parent = ads.name
+ AND ifnull(ads.finance_book, '')=%s
+ AND ads.docstatus=1
+ AND ds.parentfield='depreciation_schedule'
+ AND ds.schedule_date<=%s
+ AND ds.journal_entry IS NOT NULL
+ GROUP BY ads.asset""",
+ (cstr(filters.finance_book or ""), date),
)
)
+def get_manual_depreciation_amount_of_asset(asset, filters):
+ date = filters.to_date if filters.filter_based_on == "Date Range" else filters.year_end_date
+
+ (_, _, depreciation_expense_account) = get_depreciation_accounts(asset)
+
+ gle = frappe.qb.DocType("GL Entry")
+
+ result = (
+ frappe.qb.from_(gle)
+ .select(Sum(gle.debit))
+ .where(gle.against_voucher == asset.asset_id)
+ .where(gle.account == depreciation_expense_account)
+ .where(gle.debit != 0)
+ .where(gle.is_cancelled == 0)
+ .where(gle.posting_date <= date)
+ ).run()
+
+ if result and result[0] and result[0][0]:
+ depr_amount = result[0][0]
+ else:
+ depr_amount = 0
+
+ return depr_amount
+
+
def get_purchase_receipt_supplier_map():
return frappe._dict(
frappe.db.sql(
diff --git a/erpnext/assets/workspace/assets/assets.json b/erpnext/assets/workspace/assets/assets.json
index 26a6609b31..c07155e48a 100644
--- a/erpnext/assets/workspace/assets/assets.json
+++ b/erpnext/assets/workspace/assets/assets.json
@@ -130,6 +130,17 @@
"onboard": 0,
"type": "Link"
},
+ {
+ "dependencies": "Asset",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Asset Capitalization",
+ "link_count": 0,
+ "link_to": "Asset Capitalization",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"hidden": 0,
"is_query_report": 0,
@@ -172,7 +183,7 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:25:41.730628",
+ "modified": "2022-01-13 18:25:41.730628",
"modified_by": "Administrator",
"module": "Assets",
"name": "Assets",
@@ -205,4 +216,4 @@
}
],
"title": "Assets"
-}
\ No newline at end of file
+}
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py
index 646dba51ce..c673be89b3 100644
--- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py
@@ -15,17 +15,6 @@ class TestBulkTransactionLog(unittest.TestCase):
create_customer()
create_item()
- def test_for_single_record(self):
- so_name = create_so()
- transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice")
- data = frappe.db.get_list(
- "Sales Invoice",
- filters={"posting_date": date.today(), "customer": "Bulk Customer"},
- fields=["*"],
- )
- if not data:
- self.fail("No Sales Invoice Created !")
-
def test_entry_in_log(self):
so_name = create_so()
transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice")
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json
index 6c18a4650b..652dcf0d43 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.json
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -9,8 +9,8 @@
"supplier_and_price_defaults_section",
"supp_master_name",
"supplier_group",
- "column_break_4",
"buying_price_list",
+ "column_break_4",
"maintain_same_rate_action",
"role_to_override_stop_action",
"transaction_settings_section",
@@ -20,7 +20,8 @@
"maintain_same_rate",
"allow_multiple_items",
"bill_for_rejected_quantity_in_purchase_invoice",
- "enable_discount_accounting",
+ "disable_last_purchase_rate",
+ "show_pay_button",
"subcontract",
"backflush_raw_materials_of_subcontract_based_on",
"column_break_11",
@@ -72,11 +73,11 @@
},
{
"fieldname": "subcontract",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Subcontracting Settings"
},
{
- "default": "Material Transferred for Subcontract",
+ "default": "BOM",
"fieldname": "backflush_raw_materials_of_subcontract_based_on",
"fieldtype": "Select",
"label": "Backflush Raw Materials of Subcontract Based On",
@@ -119,8 +120,8 @@
},
{
"fieldname": "supplier_and_price_defaults_section",
- "fieldtype": "Section Break",
- "label": "Supplier and Price Defaults"
+ "fieldtype": "Tab Break",
+ "label": "Naming Series and Price Defaults"
},
{
"fieldname": "column_break_4",
@@ -128,7 +129,7 @@
},
{
"fieldname": "transaction_settings_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Transaction Settings"
},
{
@@ -137,10 +138,15 @@
},
{
"default": "0",
- "description": "If enabled, additional ledger entries will be made for discounts in a separate Discount Account",
- "fieldname": "enable_discount_accounting",
+ "fieldname": "disable_last_purchase_rate",
"fieldtype": "Check",
- "label": "Enable Discount Accounting for Buying"
+ "label": "Disable Last Purchase Rate"
+ },
+ {
+ "default": "1",
+ "fieldname": "show_pay_button",
+ "fieldtype": "Check",
+ "label": "Show Pay Button in Purchase Order Portal"
}
],
"icon": "fa fa-cog",
@@ -148,7 +154,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-05-31 19:40:26.103909",
+ "modified": "2023-02-15 14:42:10.200679",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py
index 7b18cdbedc..be1ebdeb64 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.py
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.py
@@ -5,15 +5,10 @@
import frappe
-from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.model.document import Document
-from frappe.utils import cint
class BuyingSettings(Document):
- def on_update(self):
- self.toggle_discount_accounting_fields()
-
def validate(self):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
@@ -26,60 +21,3 @@ class BuyingSettings(Document):
self.get("supp_master_name") == "Naming Series",
hide_name_field=False,
)
-
- def toggle_discount_accounting_fields(self):
- enable_discount_accounting = cint(self.enable_discount_accounting)
-
- make_property_setter(
- "Purchase Invoice Item",
- "discount_account",
- "hidden",
- not (enable_discount_accounting),
- "Check",
- validate_fields_for_doctype=False,
- )
- if enable_discount_accounting:
- make_property_setter(
- "Purchase Invoice Item",
- "discount_account",
- "mandatory_depends_on",
- "eval: doc.discount_amount",
- "Code",
- validate_fields_for_doctype=False,
- )
- else:
- make_property_setter(
- "Purchase Invoice Item",
- "discount_account",
- "mandatory_depends_on",
- "",
- "Code",
- validate_fields_for_doctype=False,
- )
-
- make_property_setter(
- "Purchase Invoice",
- "additional_discount_account",
- "hidden",
- not (enable_discount_accounting),
- "Check",
- validate_fields_for_doctype=False,
- )
- if enable_discount_accounting:
- make_property_setter(
- "Purchase Invoice",
- "additional_discount_account",
- "mandatory_depends_on",
- "eval: doc.discount_amount",
- "Code",
- validate_fields_for_doctype=False,
- )
- else:
- make_property_setter(
- "Purchase Invoice",
- "additional_discount_account",
- "mandatory_depends_on",
- "",
- "Code",
- validate_fields_for_doctype=False,
- )
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index fbb42fe2f6..47089f7d85 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -33,6 +33,7 @@ frappe.ui.form.on("Purchase Order", {
frm.set_query("fg_item", "items", function() {
return {
filters: {
+ 'is_stock_item': 1,
'is_sub_contracted_item': 1,
'default_bom': ['!=', '']
}
@@ -100,6 +101,11 @@ frappe.ui.form.on("Purchase Order", {
erpnext.queries.setup_queries(frm, "Warehouse", function() {
return erpnext.queries.warehouse(frm.doc);
});
+
+ // On cancel and amending a purchase order with advance payment, reset advance paid amount
+ if (frm.is_new()) {
+ frm.set_value("advance_paid", 0)
+ }
},
apply_tds: function(frm) {
@@ -229,11 +235,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e
cur_frm.add_custom_button(__('Purchase Invoice'),
this.make_purchase_invoice, __('Create'));
- if(flt(doc.per_billed)==0 && doc.status != "Delivered") {
+ if(flt(doc.per_billed) < 100 && doc.status != "Delivered") {
cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create'));
}
- if(flt(doc.per_billed)==0) {
+ if(flt(doc.per_billed) < 100) {
this.frm.add_custom_button(__('Payment Request'),
function() { me.make_payment_request() }, __('Create'));
}
@@ -295,131 +301,12 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e
}
make_stock_entry() {
- var items = $.map(cur_frm.doc.items, function(d) { return d.bom ? d.item_code : false; });
- var me = this;
-
- if(items.length >= 1){
- me.raw_material_data = [];
- me.show_dialog = 1;
- let title = __('Transfer Material to Supplier');
- let fields = [
- {fieldtype:'Section Break', label: __('Raw Materials')},
- {fieldname: 'sub_con_rm_items', fieldtype: 'Table', label: __('Items'),
- fields: [
- {
- fieldtype:'Data',
- fieldname:'item_code',
- label: __('Item'),
- read_only:1,
- in_list_view:1
- },
- {
- fieldtype:'Data',
- fieldname:'rm_item_code',
- label: __('Raw Material'),
- read_only:1,
- in_list_view:1
- },
- {
- fieldtype:'Float',
- read_only:1,
- fieldname:'qty',
- label: __('Quantity'),
- read_only:1,
- in_list_view:1
- },
- {
- fieldtype:'Data',
- read_only:1,
- fieldname:'warehouse',
- label: __('Reserve Warehouse'),
- in_list_view:1
- },
- {
- fieldtype:'Float',
- read_only:1,
- fieldname:'rate',
- label: __('Rate'),
- hidden:1
- },
- {
- fieldtype:'Float',
- read_only:1,
- fieldname:'amount',
- label: __('Amount'),
- hidden:1
- },
- {
- fieldtype:'Link',
- read_only:1,
- fieldname:'uom',
- label: __('UOM'),
- hidden:1
- }
- ],
- data: me.raw_material_data,
- get_data: function() {
- return me.raw_material_data;
- }
- }
- ]
-
- me.dialog = new frappe.ui.Dialog({
- title: title, fields: fields
- });
-
- if (me.frm.doc['supplied_items']) {
- me.frm.doc['supplied_items'].forEach((item, index) => {
- if (item.rm_item_code && item.main_item_code && item.required_qty - item.supplied_qty != 0) {
- me.raw_material_data.push ({
- 'name':item.name,
- 'item_code': item.main_item_code,
- 'rm_item_code': item.rm_item_code,
- 'item_name': item.rm_item_code,
- 'qty': item.required_qty - item.supplied_qty,
- 'warehouse':item.reserve_warehouse,
- 'rate':item.rate,
- 'amount':item.amount,
- 'stock_uom':item.stock_uom
- });
- me.dialog.fields_dict.sub_con_rm_items.grid.refresh();
- }
- })
- }
-
- me.dialog.get_field('sub_con_rm_items').check_all_rows()
-
- me.dialog.show()
- this.dialog.set_primary_action(__('Transfer'), function() {
- me.values = me.dialog.get_values();
- if(me.values) {
- me.values.sub_con_rm_items.map((row,i) => {
- if (!row.item_code || !row.rm_item_code || !row.warehouse || !row.qty || row.qty === 0) {
- let row_id = i+1;
- frappe.throw(__("Item Code, warehouse and quantity are required on row {0}", [row_id]));
- }
- })
- me._make_rm_stock_entry(me.dialog.fields_dict.sub_con_rm_items.grid.get_selected_children())
- me.dialog.hide()
- }
- });
- }
-
- me.dialog.get_close_btn().on('click', () => {
- me.dialog.hide();
- });
-
- }
-
- _make_rm_stock_entry(rm_items) {
frappe.call({
method:"erpnext.controllers.subcontracting_controller.make_rm_stock_entry",
args: {
subcontract_order: cur_frm.doc.name,
- rm_items: rm_items,
order_doctype: cur_frm.doc.doctype
- }
- ,
+ },
callback: function(r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json
index aa50487d78..29afc8476e 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.json
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.json
@@ -12,43 +12,24 @@
"title",
"naming_series",
"supplier",
- "get_items_from_open_material_requests",
"supplier_name",
+ "order_confirmation_no",
+ "order_confirmation_date",
+ "get_items_from_open_material_requests",
+ "column_break_7",
+ "transaction_date",
+ "schedule_date",
+ "column_break1",
+ "company",
"apply_tds",
"tax_withholding_category",
"is_subcontracted",
"supplier_warehouse",
- "column_break1",
- "company",
- "transaction_date",
- "schedule_date",
- "order_confirmation_no",
- "order_confirmation_date",
"amended_from",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
- "drop_ship",
- "customer",
- "customer_name",
- "column_break_19",
- "customer_contact_person",
- "customer_contact_display",
- "customer_contact_mobile",
- "customer_contact_email",
- "section_addresses",
- "supplier_address",
- "address_display",
- "contact_person",
- "contact_display",
- "contact_mobile",
- "contact_email",
- "col_break_address",
- "shipping_address",
- "shipping_address_display",
- "billing_address",
- "billing_address_display",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -57,21 +38,24 @@
"price_list_currency",
"plc_conversion_rate",
"ignore_pricing_rule",
- "section_break_45",
"before_items_section",
"scan_barcode",
+ "set_from_warehouse",
"items_col_break",
"set_warehouse",
"items_section",
"items",
"sb_last_purchase",
"total_qty",
+ "total_net_weight",
+ "column_break_40",
"base_total",
"base_net_total",
"column_break_26",
- "total_net_weight",
"total",
"net_total",
+ "tax_withholding_net_total",
+ "base_tax_withholding_net_total",
"section_break_48",
"pricing_rules",
"raw_material_details",
@@ -79,13 +63,14 @@
"supplied_items",
"taxes_section",
"tax_category",
- "column_break_50",
- "shipping_rule",
- "section_break_52",
"taxes_and_charges",
+ "column_break_53",
+ "shipping_rule",
+ "column_break_50",
+ "incoterm",
+ "named_place",
+ "section_break_52",
"taxes",
- "sec_tax_breakup",
- "other_charges_calculation",
"totals",
"base_taxes_and_charges_added",
"base_taxes_and_charges_deducted",
@@ -94,12 +79,6 @@
"taxes_and_charges_added",
"taxes_and_charges_deducted",
"total_taxes_and_charges",
- "discount_section",
- "apply_discount_on",
- "base_discount_amount",
- "column_break_45",
- "additional_discount_percentage",
- "discount_amount",
"totals_section",
"base_grand_total",
"base_rounding_adjustment",
@@ -112,37 +91,73 @@
"disable_rounded_total",
"in_words",
"advance_paid",
+ "discount_section",
+ "apply_discount_on",
+ "base_discount_amount",
+ "column_break_45",
+ "additional_discount_percentage",
+ "discount_amount",
+ "sec_tax_breakup",
+ "other_charges_calculation",
+ "address_and_contact_tab",
+ "section_addresses",
+ "supplier_address",
+ "address_display",
+ "col_break_address",
+ "contact_person",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "shipping_address_section",
+ "shipping_address",
+ "column_break_99",
+ "shipping_address_display",
+ "company_billing_address_section",
+ "billing_address",
+ "column_break_103",
+ "billing_address_display",
+ "drop_ship",
+ "customer",
+ "customer_name",
+ "column_break_19",
+ "customer_contact_person",
+ "customer_contact_display",
+ "customer_contact_mobile",
+ "customer_contact_email",
+ "terms_tab",
"payment_schedule_section",
"payment_terms_template",
"payment_schedule",
+ "terms_section_break",
+ "tc_name",
+ "terms",
+ "more_info_tab",
"tracking_section",
"status",
"column_break_75",
"per_billed",
"per_received",
- "terms_section_break",
- "tc_name",
- "terms",
"column_break5",
"letter_head",
- "select_print_heading",
- "column_break_86",
- "language",
"group_same_items",
+ "column_break_86",
+ "select_print_heading",
+ "language",
"subscription_section",
"from_date",
"to_date",
"column_break_97",
"auto_repeat",
"update_auto_repeat_reference",
- "more_info",
+ "additional_info_section",
+ "is_internal_supplier",
+ "represents_company",
"ref_sq",
"column_break_74",
"party_account_currency",
- "is_internal_supplier",
- "represents_company",
"inter_company_order_reference",
- "is_old_subcontracting_flow"
+ "is_old_subcontracting_flow",
+ "dashboard"
],
"fields": [
{
@@ -266,8 +281,9 @@
"read_only": 1
},
{
+ "depends_on": "eval:doc.customer",
"fieldname": "drop_ship",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Drop Ship"
},
{
@@ -298,7 +314,6 @@
{
"fieldname": "customer_contact_display",
"fieldtype": "Small Text",
- "hidden": 1,
"label": "Customer Contact",
"print_hide": 1
},
@@ -318,10 +333,9 @@
"print_hide": 1
},
{
- "collapsible": 1,
"fieldname": "section_addresses",
"fieldtype": "Section Break",
- "label": "Address and Contact"
+ "label": "Supplier Address"
},
{
"fieldname": "supplier_address",
@@ -371,7 +385,7 @@
{
"fieldname": "shipping_address",
"fieldtype": "Link",
- "label": "Company Shipping Address",
+ "label": "Shipping Address",
"options": "Address",
"print_hide": 1
},
@@ -440,12 +454,10 @@
"fieldname": "ignore_pricing_rule",
"fieldtype": "Check",
"label": "Ignore Pricing Rule",
- "no_copy": 1,
"permlevel": 1,
"print_hide": 1
},
{
- "description": "Sets 'Warehouse' in each row of the Items table.",
"fieldname": "set_warehouse",
"fieldtype": "Link",
"label": "Set Target Warehouse",
@@ -483,7 +495,6 @@
"allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
- "label": "Items",
"oldfieldname": "po_details",
"oldfieldtype": "Table",
"options": "Purchase Order Item",
@@ -570,6 +581,7 @@
"read_only": 1
},
{
+ "depends_on": "total_net_weight",
"fieldname": "total_net_weight",
"fieldtype": "Float",
"label": "Total Net Weight",
@@ -579,6 +591,8 @@
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Taxes and Charges",
"oldfieldtype": "Section Break",
"options": "fa fa-money"
},
@@ -633,7 +647,6 @@
{
"fieldname": "totals",
"fieldtype": "Section Break",
- "label": "Taxes and Charges",
"oldfieldtype": "Section Break",
"options": "fa fa-money"
},
@@ -698,7 +711,6 @@
"read_only": 1
},
{
- "depends_on": "total_taxes_and_charges",
"fieldname": "total_taxes_and_charges",
"fieldtype": "Currency",
"label": "Total Taxes and Charges",
@@ -708,7 +720,6 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "apply_discount_on",
"fieldname": "discount_section",
"fieldtype": "Section Break",
"label": "Additional Discount"
@@ -773,7 +784,6 @@
"read_only": 1
},
{
- "description": "In Words will be visible once you save the Purchase Order.",
"fieldname": "base_in_words",
"fieldtype": "Data",
"label": "In Words (Company Currency)",
@@ -851,7 +861,6 @@
"read_only": 1
},
{
- "collapsible": 1,
"fieldname": "payment_schedule_section",
"fieldtype": "Section Break",
"label": "Payment Terms"
@@ -871,11 +880,10 @@
"print_hide": 1
},
{
- "collapsible": 1,
"collapsible_depends_on": "terms",
"fieldname": "terms_section_break",
"fieldtype": "Section Break",
- "label": "Terms and Conditions",
+ "label": "Terms & Conditions",
"oldfieldtype": "Section Break",
"options": "fa fa-legal"
},
@@ -895,13 +903,6 @@
"oldfieldname": "terms",
"oldfieldtype": "Text Editor"
},
- {
- "collapsible": 1,
- "fieldname": "more_info",
- "fieldtype": "Section Break",
- "label": "More Information",
- "oldfieldtype": "Section Break"
- },
{
"default": "Draft",
"fieldname": "status",
@@ -1023,7 +1024,7 @@
"collapsible": 1,
"fieldname": "subscription_section",
"fieldtype": "Section Break",
- "label": "Subscription Section"
+ "label": "Auto Repeat"
},
{
"allow_on_submit": 1,
@@ -1098,7 +1099,9 @@
},
{
"fieldname": "before_items_section",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Items"
},
{
"fieldname": "items_col_break",
@@ -1109,7 +1112,8 @@
"fetch_from": "supplier.is_internal_supplier",
"fieldname": "is_internal_supplier",
"fieldtype": "Check",
- "label": "Is Internal Supplier"
+ "label": "Is Internal Supplier",
+ "read_only": 1
},
{
"fetch_from": "supplier.represents_company",
@@ -1133,10 +1137,6 @@
"label": "Tax Withholding Category",
"options": "Tax Withholding Category"
},
- {
- "fieldname": "section_break_45",
- "fieldtype": "Section Break"
- },
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
@@ -1166,13 +1166,112 @@
"hidden": 1,
"label": "Is Old Subcontracting Flow",
"read_only": 1
+ },
+ {
+ "depends_on": "is_internal_supplier",
+ "fieldname": "set_from_warehouse",
+ "fieldtype": "Link",
+ "label": "Set From Warehouse",
+ "options": "Warehouse"
+ },
+ {
+ "fieldname": "terms_tab",
+ "fieldtype": "Tab Break",
+ "label": "Terms"
+ },
+ {
+ "fieldname": "more_info_tab",
+ "fieldtype": "Tab Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "dashboard",
+ "fieldtype": "Tab Break",
+ "label": "Dashboard",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "column_break_7",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_40",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_53",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "address_and_contact_tab",
+ "fieldtype": "Tab Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "company_billing_address_section",
+ "fieldtype": "Section Break",
+ "label": "Company Billing Address"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "additional_info_section",
+ "fieldtype": "Section Break",
+ "label": "Additional Info",
+ "oldfieldtype": "Section Break"
+ },
+ {
+ "default": "0",
+ "depends_on": "apply_tds",
+ "fieldname": "tax_withholding_net_total",
+ "fieldtype": "Currency",
+ "hidden": 1,
+ "label": "Tax Withholding Net Total",
+ "no_copy": 1,
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "depends_on": "apply_tds",
+ "fieldname": "base_tax_withholding_net_total",
+ "fieldtype": "Currency",
+ "hidden": 1,
+ "label": "Base Tax Withholding Net Total",
+ "no_copy": 1,
+ "options": "Company:company:default_currency",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_99",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_103",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "incoterm",
+ "fieldtype": "Link",
+ "label": "Incoterm",
+ "options": "Incoterm"
+ },
+ {
+ "depends_on": "incoterm",
+ "fieldname": "named_place",
+ "fieldtype": "Data",
+ "label": "Named Place"
+ },
+ {
+ "fieldname": "shipping_address_section",
+ "fieldtype": "Section Break",
+ "label": "Shipping Address"
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2022-06-15 15:40:58.527065",
+ "modified": "2023-01-28 18:59:16.322824",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index cd58d25136..2415aec8cb 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -18,7 +18,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
-from erpnext.accounts.party import get_party_account_currency
+from erpnext.accounts.party import get_party_account, get_party_account_currency
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
@@ -207,31 +207,32 @@ class PurchaseOrder(BuyingController):
)
def validate_fg_item_for_subcontracting(self):
- if self.is_subcontracted and not self.is_old_subcontracting_flow:
+ if self.is_subcontracted:
+ if not self.is_old_subcontracting_flow:
+ for item in self.items:
+ if not item.fg_item:
+ frappe.throw(
+ _("Row #{0}: Finished Good Item is not specified for service item {1}").format(
+ item.idx, item.item_code
+ )
+ )
+ else:
+ if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"):
+ frappe.throw(
+ _("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format(
+ item.idx, item.fg_item
+ )
+ )
+ elif not frappe.get_value("Item", item.fg_item, "default_bom"):
+ frappe.throw(
+ _("Row #{0}: Default BOM not found for FG Item {1}").format(item.idx, item.fg_item)
+ )
+ if not item.fg_item_qty:
+ frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx))
+ else:
for item in self.items:
- if not item.fg_item:
- frappe.throw(
- _("Row #{0}: Finished Good Item is not specified for service item {1}").format(
- item.idx, item.item_code
- )
- )
- else:
- if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"):
- frappe.throw(
- _(
- "Row #{0}: Finished Good Item {1} must be a sub-contracted item for service item {2}"
- ).format(item.idx, item.fg_item, item.item_code)
- )
- elif not frappe.get_value("Item", item.fg_item, "default_bom"):
- frappe.throw(
- _("Row #{0}: Default BOM not found for FG Item {1}").format(item.idx, item.fg_item)
- )
- if not item.fg_item_qty:
- frappe.throw(
- _("Row #{0}: Finished Good Item Qty is not specified for service item {0}").format(
- item.idx, item.item_code
- )
- )
+ item.set("fg_item", None)
+ item.set("fg_item_qty", 0)
def get_schedule_dates(self):
for d in self.get("items"):
@@ -349,7 +350,7 @@ class PurchaseOrder(BuyingController):
update_linked_doc(self.doctype, self.name, self.inter_company_order_reference)
def on_cancel(self):
- self.ignore_linked_doctypes = "Payment Ledger Entry"
+ self.ignore_linked_doctypes = ("GL Entry", "Payment Ledger Entry")
super(PurchaseOrder, self).on_cancel()
if self.is_against_so():
@@ -361,7 +362,7 @@ class PurchaseOrder(BuyingController):
self.update_reserved_qty_for_subcontract()
self.check_on_hold_or_closed_status()
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
self.update_prevdoc_status()
@@ -558,6 +559,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
target.set_advances()
target.set_payment_schedule()
+ target.credit_to = get_party_account("Supplier", source.supplier, source.company)
def update_item(obj, target, source_parent):
target.amount = flt(obj.amount) - flt(obj.billed_amt)
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_dashboard.py b/erpnext/buying/doctype/purchase_order/purchase_order_dashboard.py
index 01b55c00d6..05b5a8e7b8 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order_dashboard.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order_dashboard.py
@@ -23,5 +23,6 @@ def get_data():
"items": ["Material Request", "Supplier Quotation", "Project", "Auto Repeat"],
},
{"label": _("Sub-contracting"), "items": ["Subcontracting Order", "Stock Entry"]},
+ {"label": _("Internal"), "items": ["Sales Order"]},
],
}
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index bd7e4e8d86..920486a78e 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -5,10 +5,13 @@
import json
import frappe
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate
+from frappe.utils.data import today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.party import get_due_date_from_template
+from erpnext.buying.doctype.purchase_order.purchase_order import make_inter_company_sales_order
from erpnext.buying.doctype.purchase_order.purchase_order import (
make_purchase_invoice as make_pi_from_po,
)
@@ -683,6 +686,12 @@ class TestPurchaseOrder(FrappeTestCase):
else:
raise Exception
+ def test_default_payment_terms(self):
+ due_date = get_due_date_from_template(
+ "_Test Payment Term Template 1", "2023-02-03", None
+ ).strftime("%Y-%m-%d")
+ self.assertEqual(due_date, "2023-03-31")
+
def test_terms_are_not_copied_if_automatically_fetch_payment_terms_is_unchecked(self):
po = create_purchase_order(do_not_save=1)
po.payment_terms_template = "_Test Payment Term Template"
@@ -707,13 +716,10 @@ class TestPurchaseOrder(FrappeTestCase):
pi.insert()
self.assertTrue(pi.get("payment_schedule"))
+ @change_settings("Accounts Settings", {"unlink_advance_payment_on_cancelation_of_order": 1})
def test_advance_payment_entry_unlink_against_purchase_order(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
- frappe.db.set_value(
- "Accounts Settings", "Accounts Settings", "unlink_advance_payment_on_cancelation_of_order", 1
- )
-
po_doc = create_purchase_order()
pe = get_payment_entry("Purchase Order", po_doc.name, bank_account="_Test Bank - _TC")
@@ -733,9 +739,33 @@ class TestPurchaseOrder(FrappeTestCase):
pe_doc = frappe.get_doc("Payment Entry", pe.name)
pe_doc.cancel()
- frappe.db.set_value(
- "Accounts Settings", "Accounts Settings", "unlink_advance_payment_on_cancelation_of_order", 0
- )
+ @change_settings("Accounts Settings", {"unlink_advance_payment_on_cancelation_of_order": 1})
+ def test_advance_paid_upon_payment_entry_cancellation(self):
+ from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
+
+ po_doc = create_purchase_order(supplier="_Test Supplier USD", currency="USD", do_not_submit=1)
+ po_doc.conversion_rate = 80
+ po_doc.submit()
+
+ pe = get_payment_entry("Purchase Order", po_doc.name)
+ pe.mode_of_payment = "Cash"
+ pe.paid_from = "Cash - _TC"
+ pe.source_exchange_rate = 1
+ pe.target_exchange_rate = 80
+ pe.paid_amount = po_doc.base_grand_total
+ pe.save(ignore_permissions=True)
+ pe.submit()
+
+ po_doc.reload()
+ self.assertEqual(po_doc.advance_paid, po_doc.grand_total)
+ self.assertEqual(po_doc.party_account_currency, "USD")
+
+ pe_doc = frappe.get_doc("Payment Entry", pe.name)
+ pe_doc.cancel()
+
+ po_doc.reload()
+ self.assertEqual(po_doc.advance_paid, 0)
+ self.assertEqual(po_doc.party_account_currency, "USD")
def test_schedule_date(self):
po = create_purchase_order(do_not_submit=True)
@@ -796,6 +826,124 @@ class TestPurchaseOrder(FrappeTestCase):
automatically_fetch_payment_terms(enable=0)
+ def test_internal_transfer_flow(self):
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
+ make_inter_company_purchase_invoice,
+ )
+ from erpnext.selling.doctype.sales_order.sales_order import (
+ make_delivery_note,
+ make_sales_invoice,
+ )
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+
+ frappe.db.set_value("Selling Settings", None, "maintain_same_sales_rate", 1)
+ frappe.db.set_value("Buying Settings", None, "maintain_same_rate", 1)
+
+ prepare_data_for_internal_transfer()
+ supplier = "_Test Internal Supplier 2"
+
+ mr = make_material_request(
+ qty=2, company="_Test Company with perpetual inventory", warehouse="Stores - TCP1"
+ )
+
+ po = create_purchase_order(
+ company="_Test Company with perpetual inventory",
+ supplier=supplier,
+ warehouse="Stores - TCP1",
+ from_warehouse="_Test Internal Warehouse New 1 - TCP1",
+ qty=2,
+ rate=1,
+ material_request=mr.name,
+ material_request_item=mr.items[0].name,
+ )
+
+ so = make_inter_company_sales_order(po.name)
+ so.items[0].delivery_date = today()
+ self.assertEqual(so.items[0].warehouse, "_Test Internal Warehouse New 1 - TCP1")
+ self.assertTrue(so.items[0].purchase_order)
+ self.assertTrue(so.items[0].purchase_order_item)
+ so.submit()
+
+ dn = make_delivery_note(so.name)
+ dn.items[0].target_warehouse = "_Test Internal Warehouse GIT - TCP1"
+ self.assertEqual(dn.items[0].warehouse, "_Test Internal Warehouse New 1 - TCP1")
+ self.assertTrue(dn.items[0].purchase_order)
+ self.assertTrue(dn.items[0].purchase_order_item)
+
+ self.assertEqual(po.items[0].name, dn.items[0].purchase_order_item)
+ dn.submit()
+
+ pr = make_inter_company_purchase_receipt(dn.name)
+ self.assertEqual(pr.items[0].warehouse, "Stores - TCP1")
+ self.assertTrue(pr.items[0].purchase_order)
+ self.assertTrue(pr.items[0].purchase_order_item)
+ self.assertEqual(po.items[0].name, pr.items[0].purchase_order_item)
+ pr.submit()
+
+ si = make_sales_invoice(so.name)
+ self.assertEqual(si.items[0].warehouse, "_Test Internal Warehouse New 1 - TCP1")
+ self.assertTrue(si.items[0].purchase_order)
+ self.assertTrue(si.items[0].purchase_order_item)
+ si.submit()
+
+ pi = make_inter_company_purchase_invoice(si.name)
+ self.assertTrue(pi.items[0].purchase_order)
+ self.assertTrue(pi.items[0].po_detail)
+ pi.submit()
+ mr.reload()
+
+ po.load_from_db()
+ self.assertEqual(po.status, "Completed")
+ self.assertEqual(mr.status, "Received")
+
+ def test_variant_item_po(self):
+ po = create_purchase_order(item_code="_Test Variant Item", qty=1, rate=100, do_not_save=1)
+
+ self.assertRaises(frappe.ValidationError, po.save)
+
+
+def prepare_data_for_internal_transfer():
+ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
+ from erpnext.selling.doctype.customer.test_customer import create_internal_customer
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ company = "_Test Company with perpetual inventory"
+
+ create_internal_customer(
+ "_Test Internal Customer 2",
+ company,
+ company,
+ )
+
+ create_internal_supplier(
+ "_Test Internal Supplier 2",
+ company,
+ company,
+ )
+
+ warehouse = create_warehouse("_Test Internal Warehouse New 1", company=company)
+
+ create_warehouse("_Test Internal Warehouse GIT", company=company)
+
+ make_purchase_receipt(company=company, warehouse=warehouse, qty=2, rate=100)
+
+ if not frappe.db.get_value("Company", company, "unrealized_profit_loss_account"):
+ account = "Unrealized Profit and Loss - TCP1"
+ if not frappe.db.exists("Account", account):
+ frappe.get_doc(
+ {
+ "doctype": "Account",
+ "account_name": "Unrealized Profit and Loss",
+ "parent_account": "Direct Income - TCP1",
+ "company": company,
+ "is_group": 0,
+ "account_type": "Income Account",
+ }
+ ).insert()
+
+ frappe.db.set_value("Company", company, "unrealized_profit_loss_account", account)
+
def make_pr_against_po(po, received_qty=0):
pr = make_purchase_receipt(po)
@@ -847,16 +995,19 @@ def create_purchase_order(**args):
{
"item_code": args.item or args.item_code or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC",
+ "from_warehouse": args.from_warehouse,
"qty": args.qty or 10,
"rate": args.rate or 500,
"schedule_date": add_days(nowdate(), 1),
"include_exploded_items": args.get("include_exploded_items", 1),
"against_blanket_order": args.against_blanket_order,
+ "material_request": args.material_request,
+ "material_request_item": args.material_request_item,
},
)
- po.set_missing_values()
if not args.do_not_save:
+ po.set_missing_values()
po.insert()
if not args.do_not_submit:
if po.is_subcontracted:
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index 1a9845396f..c645b04e12 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -10,12 +10,14 @@
"item_code",
"supplier_part_no",
"item_name",
+ "brand",
"product_bundle",
"fg_item",
"fg_item_qty",
"column_break_4",
"schedule_date",
"expected_delivery_date",
+ "item_group",
"section_break_5",
"description",
"col_break1",
@@ -51,6 +53,7 @@
"pricing_rules",
"stock_uom_rate",
"is_free_item",
+ "apply_tds",
"section_break_29",
"net_rate",
"net_amount",
@@ -58,9 +61,12 @@
"base_net_rate",
"base_net_amount",
"warehouse_and_reference",
+ "from_warehouse",
"warehouse",
+ "column_break_54",
"actual_qty",
"company_total_stock",
+ "references_section",
"material_request",
"material_request_item",
"sales_order",
@@ -73,8 +79,6 @@
"against_blanket_order",
"blanket_order",
"blanket_order_rate",
- "item_group",
- "brand",
"section_break_56",
"received_qty",
"returned_qty",
@@ -442,13 +446,13 @@
{
"fieldname": "warehouse_and_reference",
"fieldtype": "Section Break",
- "label": "Warehouse and Reference"
+ "label": "Warehouse Settings"
},
{
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
- "label": "Warehouse",
+ "label": "Target Warehouse",
"oldfieldname": "warehouse",
"oldfieldtype": "Link",
"options": "Warehouse",
@@ -760,7 +764,7 @@
"allow_on_submit": 1,
"fieldname": "actual_qty",
"fieldtype": "Float",
- "label": "Available Qty at Warehouse",
+ "label": "Available Qty at Target Warehouse",
"print_hide": 1,
"read_only": 1
},
@@ -774,6 +778,7 @@
},
{
"collapsible": 1,
+ "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
"fieldname": "discount_and_margin_section",
"fieldtype": "Section Break",
"label": "Discount and Margin"
@@ -868,13 +873,36 @@
"fieldtype": "Float",
"label": "Finished Good Item Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow"
+ },
+ {
+ "depends_on": "eval:parent.is_internal_supplier",
+ "fieldname": "from_warehouse",
+ "fieldtype": "Link",
+ "label": "From Warehouse",
+ "options": "Warehouse"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "references_section",
+ "fieldtype": "Section Break",
+ "label": "References"
+ },
+ {
+ "fieldname": "column_break_54",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "1",
+ "fieldname": "apply_tds",
+ "fieldtype": "Check",
+ "label": "Apply TDS"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-06-17 05:29:40.602349",
+ "modified": "2022-11-29 16:47:41.364387",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
index 4e29ee53ea..2f0b7862a8 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
@@ -15,10 +15,20 @@ frappe.ui.form.on("Request for Quotation",{
frm.fields_dict["suppliers"].grid.get_field("contact").get_query = function(doc, cdt, cdn) {
let d = locals[cdt][cdn];
return {
- query: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_supplier_contacts",
- filters: {'supplier': d.supplier}
- }
+ query: "frappe.contacts.doctype.contact.contact.contact_query",
+ filters: {
+ link_doctype: "Supplier",
+ link_name: d.supplier || ""
+ }
+ };
}
+
+ frm.set_query('warehouse', 'items', () => ({
+ filters: {
+ company: frm.doc.company,
+ is_group: 0
+ }
+ }));
},
onload: function(frm) {
@@ -47,44 +57,95 @@ frappe.ui.form.on("Request for Quotation",{
});
}, __("Tools"));
- frm.add_custom_button(__('Download PDF'), () => {
- var suppliers = [];
- const fields = [{
- fieldtype: 'Link',
- label: __('Select a Supplier'),
- fieldname: 'supplier',
- options: 'Supplier',
- reqd: 1,
- get_query: () => {
- return {
- filters: [
- ["Supplier", "name", "in", frm.doc.suppliers.map((row) => {return row.supplier;})]
- ]
- }
- }
- }];
-
- frappe.prompt(fields, data => {
- var child = locals[cdt][cdn]
-
- var w = window.open(
- frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?"
- +"doctype="+encodeURIComponent(frm.doc.doctype)
- +"&name="+encodeURIComponent(frm.doc.name)
- +"&supplier="+encodeURIComponent(data.supplier)
- +"&no_letterhead=0"));
- if(!w) {
- frappe.msgprint(__("Please enable pop-ups")); return;
- }
+ frm.add_custom_button(
+ __("Download PDF"),
+ () => {
+ frappe.prompt(
+ [
+ {
+ fieldtype: "Link",
+ label: "Select a Supplier",
+ fieldname: "supplier",
+ options: "Supplier",
+ reqd: 1,
+ default: frm.doc.suppliers?.length == 1 ? frm.doc.suppliers[0].supplier : "",
+ get_query: () => {
+ return {
+ filters: [
+ [
+ "Supplier",
+ "name",
+ "in",
+ frm.doc.suppliers.map((row) => {
+ return row.supplier;
+ }),
+ ],
+ ],
+ };
+ },
+ },
+ {
+ fieldtype: "Section Break",
+ label: "Print Settings",
+ fieldname: "print_settings",
+ collapsible: 1,
+ },
+ {
+ fieldtype: "Link",
+ label: "Print Format",
+ fieldname: "print_format",
+ options: "Print Format",
+ placeholder: "Standard",
+ get_query: () => {
+ return {
+ filters: {
+ doc_type: "Request for Quotation",
+ },
+ };
+ },
+ },
+ {
+ fieldtype: "Link",
+ label: "Language",
+ fieldname: "language",
+ options: "Language",
+ default: frappe.boot.lang,
+ },
+ {
+ fieldtype: "Link",
+ label: "Letter Head",
+ fieldname: "letter_head",
+ options: "Letter Head",
+ default: frm.doc.letter_head,
+ },
+ ],
+ (data) => {
+ var w = window.open(
+ frappe.urllib.get_full_url(
+ "/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" +
+ new URLSearchParams({
+ name: frm.doc.name,
+ supplier: data.supplier,
+ print_format: data.print_format || "Standard",
+ language: data.language || frappe.boot.lang,
+ letterhead: data.letter_head || frm.doc.letter_head || "",
+ }).toString()
+ )
+ );
+ if (!w) {
+ frappe.msgprint(__("Please enable pop-ups"));
+ return;
+ }
+ },
+ "Download PDF for Supplier",
+ "Download"
+ );
},
- 'Download PDF for Supplier',
- 'Download');
- },
- __("Tools"));
+ __("Tools")
+ );
- frm.page.set_inner_btn_group_as_primary(__('Create'));
+ frm.page.set_inner_btn_group_as_primary(__("Create"));
}
-
},
make_supplier_quotation: function(frm) {
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
index 083cab78f7..bd65b0c805 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
@@ -28,6 +28,8 @@
"sec_break_email_2",
"message_for_supplier",
"terms_section_break",
+ "incoterm",
+ "named_place",
"tc_name",
"terms",
"printing_settings",
@@ -271,13 +273,25 @@
"fieldname": "schedule_date",
"fieldtype": "Date",
"label": "Required Date"
+ },
+ {
+ "fieldname": "incoterm",
+ "fieldtype": "Link",
+ "label": "Incoterm",
+ "options": "Incoterm"
+ },
+ {
+ "depends_on": "incoterm",
+ "fieldname": "named_place",
+ "fieldtype": "Data",
+ "label": "Named Place"
}
],
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-06 17:47:49.909000",
+ "modified": "2023-01-31 23:22:06.684694",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
@@ -345,5 +359,6 @@
"search_fields": "status, transaction_date",
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "DESC"
-}
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
index f319506ff9..7927beb823 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
@@ -3,6 +3,7 @@
import json
+from typing import Optional
import frappe
from frappe import _
@@ -31,7 +32,7 @@ class RequestforQuotation(BuyingController):
if self.docstatus < 1:
# after amend and save, status still shows as cancelled, until submit
- frappe.db.set(self, "status", "Draft")
+ self.db_set("status", "Draft")
def validate_duplicate_supplier(self):
supplier_list = [d.supplier for d in self.suppliers]
@@ -73,14 +74,14 @@ class RequestforQuotation(BuyingController):
)
def on_submit(self):
- frappe.db.set(self, "status", "Submitted")
+ self.db_set("status", "Submitted")
for supplier in self.suppliers:
supplier.email_sent = 0
supplier.quote_status = "Pending"
self.send_to_supplier()
def on_cancel(self):
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
@frappe.whitelist()
def get_supplier_email_preview(self, supplier):
@@ -112,7 +113,7 @@ class RequestforQuotation(BuyingController):
def get_link(self):
# RFQ link for supplier portal
- return get_url("/rfq/" + self.name)
+ return get_url("/app/request-for-quotation/" + self.name)
def update_supplier_part_no(self, supplier):
self.vendor = supplier
@@ -216,6 +217,7 @@ class RequestforQuotation(BuyingController):
recipients=data.email_id,
sender=sender,
attachments=attachments,
+ print_format=self.meta.default_print_format or "Standard",
send_email=True,
doctype=self.doctype,
name=self.name,
@@ -224,9 +226,7 @@ class RequestforQuotation(BuyingController):
frappe.msgprint(_("Email Sent to Supplier {0}").format(data.supplier))
def get_attachments(self):
- attachments = [d.name for d in get_attachments(self.doctype, self.name)]
- attachments.append(frappe.attach_print(self.doctype, self.name, doc=self))
- return attachments
+ return [d.name for d in get_attachments(self.doctype, self.name)]
def update_rfq_supplier_status(self, sup_name=None):
for supplier in self.suppliers:
@@ -286,18 +286,6 @@ def get_list_context(context=None):
return list_context
-@frappe.whitelist()
-@frappe.validate_and_sanitize_search_inputs
-def get_supplier_contacts(doctype, txt, searchfield, start, page_len, filters):
- return frappe.db.sql(
- """select `tabContact`.name from `tabContact`, `tabDynamic Link`
- where `tabDynamic Link`.link_doctype = 'Supplier' and (`tabDynamic Link`.link_name=%(name)s
- and `tabDynamic Link`.link_name like %(txt)s) and `tabContact`.name = `tabDynamic Link`.parent
- limit %(page_len)s offset %(start)s""",
- {"start": start, "page_len": page_len, "txt": "%%%s%%" % txt, "name": filters.get("supplier")},
- )
-
-
@frappe.whitelist()
def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=None):
def postprocess(source, target_doc):
@@ -401,17 +389,26 @@ def create_rfq_items(sq_doc, supplier, data):
@frappe.whitelist()
-def get_pdf(doctype, name, supplier):
- doc = get_rfq_doc(doctype, name, supplier)
- if doc:
- download_pdf(doctype, name, doc=doc)
-
-
-def get_rfq_doc(doctype, name, supplier):
+def get_pdf(
+ name: str,
+ supplier: str,
+ print_format: Optional[str] = None,
+ language: Optional[str] = None,
+ letterhead: Optional[str] = None,
+):
+ doc = frappe.get_doc("Request for Quotation", name)
if supplier:
- doc = frappe.get_doc(doctype, name)
doc.update_supplier_part_no(supplier)
- return doc
+
+ # permissions get checked in `download_pdf`
+ download_pdf(
+ doc.doctype,
+ doc.name,
+ print_format,
+ doc=doc,
+ language=language,
+ letterhead=letterhead or None,
+ )
@frappe.whitelist()
@@ -490,7 +487,7 @@ def get_rfq_containing_supplier(doctype, txt, searchfield, start, page_len, filt
conditions += "and rfq.transaction_date = '{0}'".format(filters.get("transaction_date"))
rfq_data = frappe.db.sql(
- """
+ f"""
select
distinct rfq.name, rfq.transaction_date,
rfq.company
@@ -498,15 +495,18 @@ def get_rfq_containing_supplier(doctype, txt, searchfield, start, page_len, filt
`tabRequest for Quotation` rfq, `tabRequest for Quotation Supplier` rfq_supplier
where
rfq.name = rfq_supplier.parent
- and rfq_supplier.supplier = '{0}'
+ and rfq_supplier.supplier = %(supplier)s
and rfq.docstatus = 1
- and rfq.company = '{1}'
- {2}
+ and rfq.company = %(company)s
+ {conditions}
order by rfq.transaction_date ASC
- limit %(page_len)s offset %(start)s """.format(
- filters.get("supplier"), filters.get("company"), conditions
- ),
- {"page_len": page_len, "start": start},
+ limit %(page_len)s offset %(start)s """,
+ {
+ "page_len": page_len,
+ "start": start,
+ "company": filters.get("company"),
+ "supplier": filters.get("supplier"),
+ },
as_dict=1,
)
diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
index 064b806e95..d250e6f18a 100644
--- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
@@ -8,6 +8,7 @@ from frappe.utils import nowdate
from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
create_supplier_quotation,
+ get_pdf,
make_supplier_quotation_from_rfq,
)
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
@@ -124,6 +125,11 @@ class TestRequestforQuotation(FrappeTestCase):
rfq.status = "Draft"
rfq.submit()
+ def test_get_pdf(self):
+ rfq = make_request_for_quotation()
+ get_pdf(rfq.name, rfq.get("suppliers")[0].supplier)
+ self.assertEqual(frappe.local.response.type, "pdf")
+
def make_request_for_quotation(**args):
"""
diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json
index e0ee658c18..1bf7f589e2 100644
--- a/erpnext/buying/doctype/supplier/supplier.json
+++ b/erpnext/buying/doctype/supplier/supplier.json
@@ -10,34 +10,36 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
- "basic_info",
"naming_series",
"supplier_name",
"country",
- "default_bank_account",
- "tax_id",
- "tax_category",
- "tax_withholding_category",
- "image",
"column_break0",
"supplier_group",
"supplier_type",
- "allow_purchase_invoice_creation_without_purchase_order",
- "allow_purchase_invoice_creation_without_purchase_receipt",
- "is_internal_supplier",
- "represents_company",
- "disabled",
"is_transporter",
- "warn_rfqs",
- "warn_pos",
- "prevent_rfqs",
- "prevent_pos",
- "allowed_to_transact_section",
- "companies",
- "section_break_7",
+ "image",
+ "defaults_section",
"default_currency",
+ "default_bank_account",
"column_break_10",
"default_price_list",
+ "internal_supplier_section",
+ "is_internal_supplier",
+ "represents_company",
+ "column_break_16",
+ "companies",
+ "column_break2",
+ "supplier_details",
+ "column_break_30",
+ "website",
+ "language",
+ "dashboard_tab",
+ "tax_tab",
+ "tax_id",
+ "column_break_27",
+ "tax_category",
+ "tax_withholding_category",
+ "contact_and_address_tab",
"address_contacts",
"address_html",
"column_break1",
@@ -49,30 +51,26 @@
"column_break_44",
"supplier_primary_address",
"primary_address",
- "default_payable_accounts",
- "accounts",
- "section_credit_limit",
+ "accounting_tab",
"payment_terms",
- "cb_21",
+ "accounts",
+ "settings_tab",
+ "allow_purchase_invoice_creation_without_purchase_order",
+ "allow_purchase_invoice_creation_without_purchase_receipt",
+ "column_break_54",
+ "is_frozen",
+ "disabled",
+ "warn_rfqs",
+ "warn_pos",
+ "prevent_rfqs",
+ "prevent_pos",
+ "block_supplier_section",
"on_hold",
"hold_type",
- "release_date",
- "default_tax_withholding_config",
- "column_break2",
- "website",
- "supplier_details",
- "column_break_30",
- "language",
- "is_frozen"
+ "column_break_59",
+ "release_date"
],
"fields": [
- {
- "fieldname": "basic_info",
- "fieldtype": "Section Break",
- "label": "Name and Type",
- "oldfieldtype": "Section Break",
- "options": "fa fa-user"
- },
{
"fieldname": "naming_series",
"fieldtype": "Select",
@@ -192,6 +190,7 @@
"default": "0",
"fieldname": "warn_rfqs",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Warn RFQs",
"read_only": 1
},
@@ -199,6 +198,7 @@
"default": "0",
"fieldname": "warn_pos",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Warn POs",
"read_only": 1
},
@@ -206,6 +206,7 @@
"default": "0",
"fieldname": "prevent_rfqs",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Prevent RFQs",
"read_only": 1
},
@@ -213,15 +214,10 @@
"default": "0",
"fieldname": "prevent_pos",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Prevent POs",
"read_only": 1
},
- {
- "depends_on": "represents_company",
- "fieldname": "allowed_to_transact_section",
- "fieldtype": "Section Break",
- "label": "Allowed To Transact With"
- },
{
"depends_on": "represents_company",
"fieldname": "companies",
@@ -229,12 +225,6 @@
"label": "Allowed To Transact With",
"options": "Allowed To Transact With"
},
- {
- "collapsible": 1,
- "fieldname": "section_break_7",
- "fieldtype": "Section Break",
- "label": "Currency and Price List"
- },
{
"fieldname": "default_currency",
"fieldtype": "Link",
@@ -254,22 +244,12 @@
"label": "Price List",
"options": "Price List"
},
- {
- "collapsible": 1,
- "fieldname": "section_credit_limit",
- "fieldtype": "Section Break",
- "label": "Payment Terms"
- },
{
"fieldname": "payment_terms",
"fieldtype": "Link",
"label": "Default Payment Terms Template",
"options": "Payment Terms Template"
},
- {
- "fieldname": "cb_21",
- "fieldtype": "Column Break"
- },
{
"default": "0",
"fieldname": "on_hold",
@@ -315,13 +295,6 @@
"label": "Contact HTML",
"read_only": 1
},
- {
- "collapsible": 1,
- "collapsible_depends_on": "accounts",
- "fieldname": "default_payable_accounts",
- "fieldtype": "Section Break",
- "label": "Default Payable Accounts"
- },
{
"description": "Mention if non-standard payable account",
"fieldname": "accounts",
@@ -329,12 +302,6 @@
"label": "Accounts",
"options": "Party Account"
},
- {
- "collapsible": 1,
- "fieldname": "default_tax_withholding_config",
- "fieldtype": "Section Break",
- "label": "Default Tax Withholding Config"
- },
{
"collapsible": 1,
"collapsible_depends_on": "supplier_details",
@@ -383,7 +350,7 @@
{
"fieldname": "primary_address_and_contact_detail_section",
"fieldtype": "Section Break",
- "label": "Primary Address and Contact Detail"
+ "label": "Primary Address and Contact"
},
{
"description": "Reselect, if the chosen contact is edited after save",
@@ -420,6 +387,64 @@
"fieldtype": "Link",
"label": "Supplier Primary Address",
"options": "Address"
+ },
+ {
+ "fieldname": "dashboard_tab",
+ "fieldtype": "Tab Break",
+ "label": "Dashboard",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "settings_tab",
+ "fieldtype": "Tab Break",
+ "label": "Settings"
+ },
+ {
+ "fieldname": "contact_and_address_tab",
+ "fieldtype": "Tab Break",
+ "label": "Contact & Address"
+ },
+ {
+ "fieldname": "accounting_tab",
+ "fieldtype": "Tab Break",
+ "label": "Accounting"
+ },
+ {
+ "fieldname": "defaults_section",
+ "fieldtype": "Section Break",
+ "label": "Defaults"
+ },
+ {
+ "fieldname": "tax_tab",
+ "fieldtype": "Tab Break",
+ "label": "Tax"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "internal_supplier_section",
+ "fieldtype": "Section Break",
+ "label": "Internal Supplier"
+ },
+ {
+ "fieldname": "column_break_16",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_54",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "block_supplier_section",
+ "fieldtype": "Section Break",
+ "label": "Block Supplier"
+ },
+ {
+ "fieldname": "column_break_59",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-user",
@@ -432,11 +457,10 @@
"link_fieldname": "party"
}
],
- "modified": "2022-04-16 18:02:27.838623",
+ "modified": "2023-02-18 11:05:50.592270",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
- "name_case": "Title Case",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 43152e89a8..120b2f8bbe 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -20,9 +20,6 @@ from erpnext.utilities.transaction_base import TransactionBase
class Supplier(TransactionBase):
- def get_feed(self):
- return self.supplier_name
-
def onload(self):
"""Load address and contacts in `__onload`"""
load_address_and_contact(self)
@@ -145,7 +142,7 @@ class Supplier(TransactionBase):
def after_rename(self, olddn, newdn, merge=False):
if frappe.defaults.get_global_default("supp_master_name") == "Supplier Name":
- frappe.db.set(self, "supplier_name", newdn)
+ self.db_set("supplier_name", newdn)
@frappe.whitelist()
diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py
index 55722686fe..b9fc344647 100644
--- a/erpnext/buying/doctype/supplier/test_supplier.py
+++ b/erpnext/buying/doctype/supplier/test_supplier.py
@@ -3,6 +3,7 @@
import frappe
+from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.test_runner import make_test_records
from erpnext.accounts.party import get_due_date
@@ -152,6 +153,44 @@ class TestSupplier(FrappeTestCase):
# Rollback
address.delete()
+ def test_serach_fields_for_supplier(self):
+ from erpnext.controllers.queries import supplier_query
+
+ frappe.db.set_value("Buying Settings", None, "supp_master_name", "Naming Series")
+
+ supplier_name = create_supplier(supplier_name="Test Supplier 1").name
+
+ make_property_setter(
+ "Supplier", None, "search_fields", "supplier_group", "Data", for_doctype="Doctype"
+ )
+
+ data = supplier_query(
+ "Supplier", supplier_name, "name", 0, 20, filters={"name": supplier_name}, as_dict=True
+ )
+
+ self.assertEqual(data[0].name, supplier_name)
+ self.assertEqual(data[0].supplier_group, "Services")
+ self.assertTrue("supplier_type" not in data[0])
+
+ make_property_setter(
+ "Supplier",
+ None,
+ "search_fields",
+ "supplier_group, supplier_type",
+ "Data",
+ for_doctype="Doctype",
+ )
+ data = supplier_query(
+ "Supplier", supplier_name, "name", 0, 20, filters={"name": supplier_name}, as_dict=True
+ )
+
+ self.assertEqual(data[0].name, supplier_name)
+ self.assertEqual(data[0].supplier_group, "Services")
+ self.assertEqual(data[0].supplier_type, "Company")
+ self.assertTrue("supplier_type" in data[0])
+
+ frappe.db.set_value("Buying Settings", None, "supp_master_name", "Supplier Name")
+
def create_supplier(**args):
args = frappe._dict(args)
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index 8d1939a101..c5b369bedd 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -13,19 +13,13 @@
"naming_series",
"supplier",
"supplier_name",
- "column_break1",
"company",
+ "column_break1",
+ "status",
"transaction_date",
"valid_till",
"quotation_number",
"amended_from",
- "address_section",
- "supplier_address",
- "contact_person",
- "address_display",
- "contact_display",
- "contact_mobile",
- "contact_email",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -36,25 +30,25 @@
"ignore_pricing_rule",
"items_section",
"items",
- "pricing_rule_details",
- "pricing_rules",
"section_break_22",
"total_qty",
+ "total_net_weight",
+ "column_break_26",
"base_total",
"base_net_total",
"column_break_24",
"total",
"net_total",
- "total_net_weight",
"taxes_section",
"tax_category",
- "column_break_36",
- "shipping_rule",
- "section_break_38",
"taxes_and_charges",
+ "column_break_34",
+ "shipping_rule",
+ "column_break_36",
+ "incoterm",
+ "named_place",
+ "section_break_38",
"taxes",
- "tax_breakup",
- "other_charges_calculation",
"totals",
"base_taxes_and_charges_added",
"base_taxes_and_charges_deducted",
@@ -80,24 +74,36 @@
"rounded_total",
"in_words",
"disable_rounded_total",
- "terms_section_break",
+ "tax_breakup",
+ "other_charges_calculation",
+ "pricing_rule_details",
+ "pricing_rules",
+ "address_and_contact_tab",
+ "supplier_address",
+ "address_display",
+ "column_break_72",
+ "contact_person",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "terms_tab",
"tc_name",
"terms",
+ "more_info_tab",
"printing_settings",
- "select_print_heading",
- "group_same_items",
- "column_break_72",
"letter_head",
+ "group_same_items",
+ "column_break_85",
+ "select_print_heading",
"language",
"subscription_section",
"auto_repeat",
"update_auto_repeat_reference",
"more_info",
- "status",
- "column_break_57",
"is_subcontracted",
- "reference",
- "opportunity"
+ "column_break_57",
+ "opportunity",
+ "connections_tab"
],
"fields": [
{
@@ -146,7 +152,7 @@
"fieldname": "supplier_name",
"fieldtype": "Data",
"in_global_search": 1,
- "label": "Name",
+ "label": "Supplier Name",
"read_only": 1
},
{
@@ -193,12 +199,6 @@
"reqd": 1,
"search_index": 1
},
- {
- "collapsible": 1,
- "fieldname": "address_section",
- "fieldtype": "Section Break",
- "label": "Address and Contact"
- },
{
"fieldname": "supplier_address",
"fieldtype": "Link",
@@ -309,6 +309,8 @@
{
"fieldname": "items_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Items",
"oldfieldtype": "Section Break",
"options": "fa fa-shopping-cart"
},
@@ -316,7 +318,6 @@
"allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
- "label": "Items",
"oldfieldname": "po_details",
"oldfieldtype": "Table",
"options": "Supplier Quotation Item",
@@ -394,6 +395,7 @@
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
+ "hide_border": 1,
"label": "Taxes and Charges",
"oldfieldtype": "Section Break",
"options": "fa fa-money"
@@ -417,7 +419,8 @@
},
{
"fieldname": "section_break_38",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1
},
{
"fieldname": "taxes_and_charges",
@@ -523,7 +526,6 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "discount_amount",
"fieldname": "section_break_41",
"fieldtype": "Section Break",
"label": "Additional Discount"
@@ -563,7 +565,8 @@
},
{
"fieldname": "section_break_46",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "label": "Totals"
},
{
"fieldname": "base_grand_total",
@@ -654,19 +657,10 @@
"fieldtype": "Check",
"label": "Disable Rounded Total"
},
- {
- "collapsible": 1,
- "collapsible_depends_on": "terms",
- "fieldname": "terms_section_break",
- "fieldtype": "Section Break",
- "label": "Terms and Conditions",
- "oldfieldtype": "Section Break",
- "options": "fa fa-legal"
- },
{
"fieldname": "tc_name",
"fieldtype": "Link",
- "label": "Terms",
+ "label": "Terms Template",
"oldfieldname": "tc_name",
"oldfieldtype": "Link",
"options": "Terms and Conditions",
@@ -729,7 +723,7 @@
{
"fieldname": "subscription_section",
"fieldtype": "Section Break",
- "label": "Auto Repeat Section"
+ "label": "Auto Repeat"
},
{
"fieldname": "auto_repeat",
@@ -751,7 +745,7 @@
"collapsible": 1,
"fieldname": "more_info",
"fieldtype": "Section Break",
- "label": "More Information",
+ "label": "Additional Info",
"oldfieldtype": "Section Break",
"options": "fa fa-file-text"
},
@@ -779,11 +773,6 @@
"label": "Is Subcontracted",
"print_hide": 1
},
- {
- "fieldname": "reference",
- "fieldtype": "Section Break",
- "label": "Reference"
- },
{
"fieldname": "opportunity",
"fieldtype": "Link",
@@ -803,6 +792,51 @@
"fieldname": "quotation_number",
"fieldtype": "Data",
"label": "Quotation Number"
+ },
+ {
+ "fieldname": "address_and_contact_tab",
+ "fieldtype": "Tab Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "terms_tab",
+ "fieldtype": "Tab Break",
+ "label": "Terms"
+ },
+ {
+ "fieldname": "more_info_tab",
+ "fieldtype": "Tab Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "column_break_26",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_34",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_85",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "incoterm",
+ "fieldtype": "Link",
+ "label": "Incoterm",
+ "options": "Incoterm"
+ },
+ {
+ "depends_on": "incoterm",
+ "fieldname": "named_place",
+ "fieldtype": "Data",
+ "label": "Named Place"
}
],
"icon": "fa fa-shopping-cart",
@@ -810,7 +844,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-14 16:13:20.284572",
+ "modified": "2022-12-12 18:35:39.740974",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
index c19c1df180..2dd748bc19 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
@@ -30,11 +30,11 @@ class SupplierQuotation(BuyingController):
self.validate_valid_till()
def on_submit(self):
- frappe.db.set(self, "status", "Submitted")
+ self.db_set("status", "Submitted")
self.update_rfq_supplier_status(1)
def on_cancel(self):
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
self.update_rfq_supplier_status(0)
def on_trash(self):
diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py
index d70ac46ce3..71019e8037 100644
--- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py
+++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py
@@ -127,32 +127,27 @@ def get_columns(filters):
return columns
-def get_conditions(filters):
- conditions = ""
-
+def apply_filters_on_query(filters, parent, child, query):
if filters.get("company"):
- conditions += " AND parent.company=%s" % frappe.db.escape(filters.get("company"))
+ query = query.where(parent.company == filters.get("company"))
if filters.get("cost_center") or filters.get("project"):
- conditions += """
- AND (child.`cost_center`=%s OR child.`project`=%s)
- """ % (
- frappe.db.escape(filters.get("cost_center")),
- frappe.db.escape(filters.get("project")),
+ query = query.where(
+ (child.cost_center == filters.get("cost_center")) | (child.project == filters.get("project"))
)
if filters.get("from_date"):
- conditions += " AND parent.transaction_date>='%s'" % filters.get("from_date")
+ query = query.where(parent.transaction_date >= filters.get("from_date"))
if filters.get("to_date"):
- conditions += " AND parent.transaction_date<='%s'" % filters.get("to_date")
- return conditions
+ query = query.where(parent.transaction_date <= filters.get("to_date"))
+
+ return query
def get_data(filters):
- conditions = get_conditions(filters)
- purchase_order_entry = get_po_entries(conditions)
- mr_records, procurement_record_against_mr = get_mapped_mr_details(conditions)
+ purchase_order_entry = get_po_entries(filters)
+ mr_records, procurement_record_against_mr = get_mapped_mr_details(filters)
pr_records = get_mapped_pr_records()
pi_records = get_mapped_pi_records()
@@ -187,11 +182,15 @@ def get_data(filters):
return procurement_record
-def get_mapped_mr_details(conditions):
+def get_mapped_mr_details(filters):
mr_records = {}
- mr_details = frappe.db.sql(
- """
- SELECT
+ parent = frappe.qb.DocType("Material Request")
+ child = frappe.qb.DocType("Material Request Item")
+
+ query = (
+ frappe.qb.from_(parent)
+ .from_(child)
+ .select(
parent.transaction_date,
parent.per_ordered,
parent.owner,
@@ -203,18 +202,13 @@ def get_mapped_mr_details(conditions):
child.uom,
parent.status,
child.project,
- child.cost_center
- FROM `tabMaterial Request` parent, `tabMaterial Request Item` child
- WHERE
- parent.per_ordered>=0
- AND parent.name=child.parent
- AND parent.docstatus=1
- {conditions}
- """.format(
- conditions=conditions
- ),
- as_dict=1,
- ) # nosec
+ child.cost_center,
+ )
+ .where((parent.per_ordered >= 0) & (parent.name == child.parent) & (parent.docstatus == 1))
+ )
+ query = apply_filters_on_query(filters, parent, child, query)
+
+ mr_details = query.run(as_dict=True)
procurement_record_against_mr = []
for record in mr_details:
@@ -241,46 +235,49 @@ def get_mapped_mr_details(conditions):
def get_mapped_pi_records():
- return frappe._dict(
- frappe.db.sql(
- """
- SELECT
- pi_item.po_detail,
- pi_item.base_amount
- FROM `tabPurchase Invoice Item` as pi_item
- INNER JOIN `tabPurchase Order` as po
- ON pi_item.`purchase_order` = po.`name`
- WHERE
- pi_item.docstatus = 1
- AND po.status not in ('Closed','Completed','Cancelled')
- AND pi_item.po_detail IS NOT NULL
- """
+ po = frappe.qb.DocType("Purchase Order")
+ pi_item = frappe.qb.DocType("Purchase Invoice Item")
+ pi_records = (
+ frappe.qb.from_(pi_item)
+ .inner_join(po)
+ .on(pi_item.purchase_order == po.name)
+ .select(pi_item.po_detail, pi_item.base_amount)
+ .where(
+ (pi_item.docstatus == 1)
+ & (po.status.notin(("Closed", "Completed", "Cancelled")))
+ & (pi_item.po_detail.isnotnull())
)
- )
+ ).run()
+
+ return frappe._dict(pi_records)
def get_mapped_pr_records():
- return frappe._dict(
- frappe.db.sql(
- """
- SELECT
- pr_item.purchase_order_item,
- pr.posting_date
- FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pr_item
- WHERE
- pr.docstatus=1
- AND pr.name=pr_item.parent
- AND pr_item.purchase_order_item IS NOT NULL
- AND pr.status not in ('Closed','Completed','Cancelled')
- """
+ pr = frappe.qb.DocType("Purchase Receipt")
+ pr_item = frappe.qb.DocType("Purchase Receipt Item")
+ pr_records = (
+ frappe.qb.from_(pr)
+ .from_(pr_item)
+ .select(pr_item.purchase_order_item, pr.posting_date)
+ .where(
+ (pr.docstatus == 1)
+ & (pr.name == pr_item.parent)
+ & (pr_item.purchase_order_item.isnotnull())
+ & (pr.status.notin(("Closed", "Completed", "Cancelled")))
)
- )
+ ).run()
+
+ return frappe._dict(pr_records)
-def get_po_entries(conditions):
- return frappe.db.sql(
- """
- SELECT
+def get_po_entries(filters):
+ parent = frappe.qb.DocType("Purchase Order")
+ child = frappe.qb.DocType("Purchase Order Item")
+
+ query = (
+ frappe.qb.from_(parent)
+ .from_(child)
+ .select(
child.name,
child.parent,
child.cost_center,
@@ -297,17 +294,15 @@ def get_po_entries(conditions):
parent.transaction_date,
parent.supplier,
parent.status,
- parent.owner
- FROM `tabPurchase Order` parent, `tabPurchase Order Item` child
- WHERE
- parent.docstatus = 1
- AND parent.name = child.parent
- AND parent.status not in ('Closed','Completed','Cancelled')
- {conditions}
- GROUP BY
- parent.name, child.item_code
- """.format(
- conditions=conditions
- ),
- as_dict=1,
- ) # nosec
+ parent.owner,
+ )
+ .where(
+ (parent.docstatus == 1)
+ & (parent.name == child.parent)
+ & (parent.status.notin(("Closed", "Completed", "Cancelled")))
+ )
+ .groupby(parent.name, child.item_code)
+ )
+ query = apply_filters_on_query(filters, parent, child, query)
+
+ return query.run(as_dict=True)
diff --git a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py
index 47a66ad46f..9b53421319 100644
--- a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py
+++ b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py
@@ -15,60 +15,4 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
class TestProcurementTracker(FrappeTestCase):
- def test_result_for_procurement_tracker(self):
- filters = {"company": "_Test Procurement Company", "cost_center": "Main - _TPC"}
- expected_data = self.generate_expected_data()
- report = execute(filters)
-
- length = len(report[1])
- self.assertEqual(expected_data, report[1][length - 1])
-
- def generate_expected_data(self):
- if not frappe.db.exists("Company", "_Test Procurement Company"):
- frappe.get_doc(
- dict(
- doctype="Company",
- company_name="_Test Procurement Company",
- abbr="_TPC",
- default_currency="INR",
- country="Pakistan",
- )
- ).insert()
- warehouse = create_warehouse("_Test Procurement Warehouse", company="_Test Procurement Company")
- mr = make_material_request(
- company="_Test Procurement Company", warehouse=warehouse, cost_center="Main - _TPC"
- )
- po = make_purchase_order(mr.name)
- po.supplier = "_Test Supplier"
- po.get("items")[0].cost_center = "Main - _TPC"
- po.submit()
- pr = make_purchase_receipt(po.name)
- pr.get("items")[0].cost_center = "Main - _TPC"
- pr.submit()
- date_obj = datetime.date(datetime.now())
-
- po.load_from_db()
-
- expected_data = {
- "material_request_date": date_obj,
- "cost_center": "Main - _TPC",
- "project": None,
- "requesting_site": "_Test Procurement Warehouse - _TPC",
- "requestor": "Administrator",
- "material_request_no": mr.name,
- "item_code": "_Test Item",
- "quantity": 10.0,
- "unit_of_measurement": "_Test UOM",
- "status": "To Bill",
- "purchase_order_date": date_obj,
- "purchase_order": po.name,
- "supplier": "_Test Supplier",
- "estimated_cost": 0.0,
- "actual_cost": 0.0,
- "purchase_order_amt": po.net_total,
- "purchase_order_amt_in_company_currency": po.base_net_total,
- "expected_delivery_date": date_obj,
- "actual_delivery_date": date_obj,
- }
-
- return expected_data
+ pass
diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py
index a5c464910d..e10c0e2fcc 100644
--- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py
+++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py
@@ -6,6 +6,7 @@ import copy
import frappe
from frappe import _
+from frappe.query_builder.functions import IfNull
from frappe.utils import date_diff, flt, getdate
@@ -16,9 +17,7 @@ def execute(filters=None):
validate_filters(filters)
columns = get_columns(filters)
- conditions = get_conditions(filters)
-
- data = get_data(conditions, filters)
+ data = get_data(filters)
if not data:
return [], [], None, []
@@ -37,60 +36,61 @@ def validate_filters(filters):
frappe.throw(_("To Date cannot be before From Date."))
-def get_conditions(filters):
- conditions = ""
- if filters.get("from_date") and filters.get("to_date"):
- conditions += " and po.transaction_date between %(from_date)s and %(to_date)s"
+def get_data(filters):
+ po = frappe.qb.DocType("Purchase Order")
+ po_item = frappe.qb.DocType("Purchase Order Item")
+ pi_item = frappe.qb.DocType("Purchase Invoice Item")
- for field in ["company", "name"]:
+ query = (
+ frappe.qb.from_(po)
+ .from_(po_item)
+ .left_join(pi_item)
+ .on(pi_item.po_detail == po_item.name)
+ .select(
+ po.transaction_date.as_("date"),
+ po_item.schedule_date.as_("required_date"),
+ po_item.project,
+ po.name.as_("purchase_order"),
+ po.status,
+ po.supplier,
+ po_item.item_code,
+ po_item.qty,
+ po_item.received_qty,
+ (po_item.qty - po_item.received_qty).as_("pending_qty"),
+ IfNull(pi_item.qty, 0).as_("billed_qty"),
+ po_item.base_amount.as_("amount"),
+ (po_item.received_qty * po_item.base_rate).as_("received_qty_amount"),
+ (po_item.billed_amt * IfNull(po.conversion_rate, 1)).as_("billed_amount"),
+ (po_item.base_amount - (po_item.billed_amt * IfNull(po.conversion_rate, 1))).as_(
+ "pending_amount"
+ ),
+ po.set_warehouse.as_("warehouse"),
+ po.company,
+ po_item.name,
+ )
+ .where(
+ (po_item.parent == po.name) & (po.status.notin(("Stopped", "Closed"))) & (po.docstatus == 1)
+ )
+ .groupby(po_item.name)
+ .orderby(po.transaction_date)
+ )
+
+ for field in ("company", "name"):
if filters.get(field):
- conditions += f" and po.{field} = %({field})s"
+ query = query.where(po[field] == filters.get(field))
+
+ if filters.get("from_date") and filters.get("to_date"):
+ query = query.where(
+ po.transaction_date.between(filters.get("from_date"), filters.get("to_date"))
+ )
if filters.get("status"):
- conditions += " and po.status in %(status)s"
+ query = query.where(po.status.isin(filters.get("status")))
if filters.get("project"):
- conditions += " and poi.project = %(project)s"
+ query = query.where(po_item.project == filters.get("project"))
- return conditions
-
-
-def get_data(conditions, filters):
- data = frappe.db.sql(
- """
- SELECT
- po.transaction_date as date,
- poi.schedule_date as required_date,
- poi.project,
- po.name as purchase_order,
- po.status, po.supplier, poi.item_code,
- poi.qty, poi.received_qty,
- (poi.qty - poi.received_qty) AS pending_qty,
- IFNULL(pii.qty, 0) as billed_qty,
- poi.base_amount as amount,
- (poi.received_qty * poi.base_rate) as received_qty_amount,
- (poi.billed_amt * IFNULL(po.conversion_rate, 1)) as billed_amount,
- (poi.base_amount - (poi.billed_amt * IFNULL(po.conversion_rate, 1))) as pending_amount,
- po.set_warehouse as warehouse,
- po.company, poi.name
- FROM
- `tabPurchase Order` po,
- `tabPurchase Order Item` poi
- LEFT JOIN `tabPurchase Invoice Item` pii
- ON pii.po_detail = poi.name
- WHERE
- poi.parent = po.name
- and po.status not in ('Stopped', 'Closed')
- and po.docstatus = 1
- {0}
- GROUP BY poi.name
- ORDER BY po.transaction_date ASC
- """.format(
- conditions
- ),
- filters,
- as_dict=1,
- )
+ data = query.run(as_dict=True)
return data
diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
index dbdc62e9ec..d089473a16 100644
--- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
+++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
@@ -53,4 +53,5 @@ def get_chart_data(data, conditions, filters):
},
"type": "line",
"lineOptions": {"regionFill": 1},
+ "fieldtype": "Currency",
}
diff --git a/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py b/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
index c772c1a1b1..d13d9701f3 100644
--- a/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
+++ b/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
@@ -4,6 +4,8 @@
# Decompiled by https://python-decompiler.com
+import copy
+
import frappe
from frappe.tests.utils import FrappeTestCase
@@ -11,10 +13,12 @@ from erpnext.buying.report.subcontracted_item_to_be_received.subcontracted_item_
execute,
)
from erpnext.controllers.tests.test_subcontracting_controller import (
+ get_rm_items,
get_subcontracting_order,
make_service_item,
+ make_stock_in_entry,
+ make_stock_transfer_entry,
)
-from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt,
)
@@ -36,15 +40,18 @@ class TestSubcontractedItemToBeReceived(FrappeTestCase):
sco = get_subcontracting_order(
service_items=service_items, supplier_warehouse="_Test Warehouse 1 - _TC"
)
- make_stock_entry(
- item_code="_Test Item", target="_Test Warehouse 1 - _TC", qty=100, basic_rate=100
- )
- make_stock_entry(
- item_code="_Test Item Home Desktop 100",
- target="_Test Warehouse 1 - _TC",
- qty=100,
- basic_rate=100,
+ rm_items = get_rm_items(sco.supplied_items)
+ itemwise_details = make_stock_in_entry(rm_items=rm_items)
+
+ for item in rm_items:
+ item["sco_rm_detail"] = sco.items[0].name
+
+ make_stock_transfer_entry(
+ sco_no=sco.name,
+ rm_items=rm_items,
+ itemwise_details=copy.deepcopy(itemwise_details),
)
+
make_subcontracting_receipt_against_sco(sco.name)
sco.reload()
col, data = execute(
diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
index 3013b6d160..a728290961 100644
--- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
+++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
@@ -16,8 +16,7 @@ def execute(filters=None):
return [], []
columns = get_columns(filters)
- conditions = get_conditions(filters)
- supplier_quotation_data = get_data(filters, conditions)
+ supplier_quotation_data = get_data(filters)
data, chart_data = prepare_data(supplier_quotation_data, filters)
message = get_message()
@@ -25,50 +24,51 @@ def execute(filters=None):
return columns, data, message, chart_data
-def get_conditions(filters):
- conditions = ""
+def get_data(filters):
+ sq = frappe.qb.DocType("Supplier Quotation")
+ sq_item = frappe.qb.DocType("Supplier Quotation Item")
+
+ query = (
+ frappe.qb.from_(sq_item)
+ .from_(sq)
+ .select(
+ sq_item.parent,
+ sq_item.item_code,
+ sq_item.qty,
+ sq_item.stock_qty,
+ sq_item.amount,
+ sq_item.uom,
+ sq_item.stock_uom,
+ sq_item.request_for_quotation,
+ sq_item.lead_time_days,
+ sq.supplier.as_("supplier_name"),
+ sq.valid_till,
+ )
+ .where(
+ (sq_item.parent == sq.name)
+ & (sq_item.docstatus < 2)
+ & (sq.company == filters.get("company"))
+ & (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
+ )
+ .orderby(sq.transaction_date, sq_item.item_code)
+ )
+
if filters.get("item_code"):
- conditions += " AND sqi.item_code = %(item_code)s"
+ query = query.where(sq_item.item_code == filters.get("item_code"))
if filters.get("supplier_quotation"):
- conditions += " AND sqi.parent in %(supplier_quotation)s"
+ query = query.where(sq_item.parent.isin(filters.get("supplier_quotation")))
if filters.get("request_for_quotation"):
- conditions += " AND sqi.request_for_quotation = %(request_for_quotation)s"
+ query = query.where(sq_item.request_for_quotation == filters.get("request_for_quotation"))
if filters.get("supplier"):
- conditions += " AND sq.supplier in %(supplier)s"
+ query = query.where(sq.supplier.isin(filters.get("supplier")))
if not filters.get("include_expired"):
- conditions += " AND sq.status != 'Expired'"
+ query = query.where(sq.status != "Expired")
- return conditions
-
-
-def get_data(filters, conditions):
- supplier_quotation_data = frappe.db.sql(
- """
- SELECT
- sqi.parent, sqi.item_code,
- sqi.qty, sqi.stock_qty, sqi.amount,
- sqi.uom, sqi.stock_uom,
- sqi.request_for_quotation,
- sqi.lead_time_days, sq.supplier as supplier_name, sq.valid_till
- FROM
- `tabSupplier Quotation Item` sqi,
- `tabSupplier Quotation` sq
- WHERE
- sqi.parent = sq.name
- AND sqi.docstatus < 2
- AND sq.company = %(company)s
- AND sq.transaction_date between %(from_date)s and %(to_date)s
- {0}
- order by sq.transaction_date, sqi.item_code""".format(
- conditions
- ),
- filters,
- as_dict=1,
- )
+ supplier_quotation_data = query.run(as_dict=True)
return supplier_quotation_data
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 70d2bc68a1..3705fcf499 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -7,7 +7,7 @@ import json
import frappe
from frappe import _, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
-from frappe.query_builder.functions import Sum
+from frappe.query_builder.functions import Abs, Sum
from frappe.utils import (
add_days,
add_months,
@@ -151,6 +151,7 @@ class AccountsController(TransactionBase):
self.validate_inter_company_reference()
self.disable_pricing_rule_on_internal_transfer()
+ self.disable_tax_included_prices_for_internal_transfer()
self.set_incoming_rate()
if self.meta.get_field("currency"):
@@ -196,15 +197,25 @@ class AccountsController(TransactionBase):
validate_einvoice_fields(self)
- if self.doctype != "Material Request":
+ if self.doctype != "Material Request" and not self.ignore_pricing_rule:
apply_pricing_rule_on_transaction(self)
def before_cancel(self):
validate_einvoice_fields(self)
def on_trash(self):
+ # delete references in 'Repost Payment Ledger'
+ rpi = frappe.qb.DocType("Repost Payment Ledger Items")
+ frappe.qb.from_(rpi).delete().where(
+ (rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name)
+ ).run()
+
# delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"):
+ ple = frappe.qb.DocType("Payment Ledger Entry")
+ frappe.qb.from_(ple).delete().where(
+ (ple.voucher_type == self.doctype) & (ple.voucher_no == self.name)
+ ).run()
frappe.db.sql(
"delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name)
)
@@ -222,7 +233,7 @@ class AccountsController(TransactionBase):
for item in self.get("items"):
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
if not item.get(field_map.get(self.doctype)):
- default_deferred_account = frappe.db.get_value(
+ default_deferred_account = frappe.get_cached_value(
"Company", self.company, "default_" + field_map.get(self.doctype)
)
if not default_deferred_account:
@@ -234,6 +245,14 @@ class AccountsController(TransactionBase):
else:
item.set(field_map.get(self.doctype), default_deferred_account)
+ def validate_auto_repeat_subscription_dates(self):
+ if (
+ self.get("from_date")
+ and self.get("to_date")
+ and getdate(self.from_date) > getdate(self.to_date)
+ ):
+ frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
+
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
@@ -373,7 +392,7 @@ class AccountsController(TransactionBase):
)
def validate_inter_company_reference(self):
- if self.doctype not in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"):
+ if self.doctype not in ("Purchase Invoice", "Purchase Receipt"):
return
if self.is_internal_transfer():
@@ -381,7 +400,7 @@ class AccountsController(TransactionBase):
self.get("inter_company_reference")
or self.get("inter_company_invoice_reference")
or self.get("inter_company_order_reference")
- ):
+ ) and not self.get("is_return"):
msg = _("Internal Sale or Delivery Reference missing.")
msg += _("Please create purchase from internal sale or delivery document itself")
frappe.throw(msg, title=_("Internal Sales Reference Missing"))
@@ -394,6 +413,20 @@ class AccountsController(TransactionBase):
alert=1,
)
+ def disable_tax_included_prices_for_internal_transfer(self):
+ if self.is_internal_transfer():
+ tax_updated = False
+ for tax in self.get("taxes"):
+ if tax.get("included_in_print_rate"):
+ tax.included_in_print_rate = 0
+ tax_updated = True
+
+ if tax_updated:
+ frappe.msgprint(
+ _("Disabled tax included prices since this {} is an internal transfer").format(self.doctype),
+ alert=1,
+ )
+
def validate_due_date(self):
if self.get("is_pos"):
return
@@ -557,7 +590,12 @@ class AccountsController(TransactionBase):
if bool(uom) != bool(stock_uom): # xor
item.stock_uom = item.uom = uom or stock_uom
- item.conversion_factor = get_uom_conv_factor(item.get("uom"), item.get("stock_uom"))
+ # UOM cannot be zero so substitute as 1
+ item.conversion_factor = (
+ get_uom_conv_factor(item.get("uom"), item.get("stock_uom"))
+ or item.get("conversion_factor")
+ or 1
+ )
if self.doctype == "Purchase Invoice":
self.set_expense_account(for_validate)
@@ -567,6 +605,11 @@ class AccountsController(TransactionBase):
# if user changed the discount percentage then set user's discount percentage ?
if pricing_rule_args.get("price_or_product_discount") == "Price":
item.set("pricing_rules", pricing_rule_args.get("pricing_rules"))
+ if pricing_rule_args.get("apply_rule_on_other_items"):
+ other_items = json.loads(pricing_rule_args.get("apply_rule_on_other_items"))
+ if other_items and item.item_code not in other_items:
+ return
+
item.set("discount_percentage", pricing_rule_args.get("discount_percentage"))
item.set("discount_amount", pricing_rule_args.get("discount_amount"))
if pricing_rule_args.get("pricing_rule_for") == "Rate":
@@ -652,7 +695,7 @@ class AccountsController(TransactionBase):
def validate_enabled_taxes_and_charges(self):
taxes_and_charges_doctype = self.meta.get_options("taxes_and_charges")
- if frappe.db.get_value(taxes_and_charges_doctype, self.taxes_and_charges, "disabled"):
+ if frappe.get_cached_value(taxes_and_charges_doctype, self.taxes_and_charges, "disabled"):
frappe.throw(
_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, self.taxes_and_charges)
)
@@ -660,7 +703,7 @@ class AccountsController(TransactionBase):
def validate_tax_account_company(self):
for d in self.get("taxes"):
if d.account_head:
- tax_account_company = frappe.db.get_value("Account", d.account_head, "company")
+ tax_account_company = frappe.get_cached_value("Account", d.account_head, "company")
if tax_account_company != self.company:
frappe.throw(
_("Row #{0}: Account {1} does not belong to company {2}").format(
@@ -795,15 +838,12 @@ class AccountsController(TransactionBase):
self.set("advances", [])
advance_allocated = 0
for d in res:
- if d.against_order:
- allocated_amount = flt(d.amount)
+ if self.get("party_account_currency") == self.company_currency:
+ amount = self.get("base_rounded_total") or self.base_grand_total
else:
- if self.get("party_account_currency") == self.company_currency:
- amount = self.get("base_rounded_total") or self.base_grand_total
- else:
- amount = self.get("rounded_total") or self.grand_total
+ amount = self.get("rounded_total") or self.grand_total
- allocated_amount = min(amount - advance_allocated, d.amount)
+ allocated_amount = min(amount - advance_allocated, d.amount)
advance_allocated += flt(allocated_amount)
advance_row = {
@@ -908,7 +948,9 @@ class AccountsController(TransactionBase):
party_account = self.credit_to if is_purchase_invoice else self.debit_to
party_type = "Supplier" if is_purchase_invoice else "Customer"
- gain_loss_account = frappe.db.get_value("Company", self.company, "exchange_gain_loss_account")
+ gain_loss_account = frappe.get_cached_value(
+ "Company", self.company, "exchange_gain_loss_account"
+ )
if not gain_loss_account:
frappe.throw(
_("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company"))
@@ -1005,7 +1047,7 @@ class AccountsController(TransactionBase):
else self.grand_total
),
"outstanding_amount": self.outstanding_amount,
- "difference_account": frappe.db.get_value(
+ "difference_account": frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
),
"exchange_gain_loss": flt(d.get("exchange_gain_loss")),
@@ -1109,17 +1151,17 @@ class AccountsController(TransactionBase):
frappe.db.get_single_value("Selling Settings", "enable_discount_accounting")
)
+ if self.doctype == "Purchase Invoice":
+ dr_or_cr = "credit"
+ rev_dr_cr = "debit"
+ supplier_or_customer = self.supplier
+
+ else:
+ dr_or_cr = "debit"
+ rev_dr_cr = "credit"
+ supplier_or_customer = self.customer
+
if enable_discount_accounting:
- if self.doctype == "Purchase Invoice":
- dr_or_cr = "credit"
- rev_dr_cr = "debit"
- supplier_or_customer = self.supplier
-
- else:
- dr_or_cr = "debit"
- rev_dr_cr = "credit"
- supplier_or_customer = self.customer
-
for item in self.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
discount_amount = item.discount_amount * item.qty
@@ -1173,18 +1215,22 @@ class AccountsController(TransactionBase):
)
)
- if self.get("discount_amount") and self.get("additional_discount_account"):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.additional_discount_account,
- "against": supplier_or_customer,
- dr_or_cr: self.discount_amount,
- "cost_center": self.cost_center,
- },
- item=self,
- )
+ if (
+ (enable_discount_accounting or self.get("is_cash_or_non_trade_discount"))
+ and self.get("additional_discount_account")
+ and self.get("discount_amount")
+ ):
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": self.additional_discount_account,
+ "against": supplier_or_customer,
+ dr_or_cr: self.discount_amount,
+ "cost_center": self.cost_center,
+ },
+ item=self,
)
+ )
def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield):
from erpnext.controllers.status_updater import get_allowance_for
@@ -1321,30 +1367,20 @@ class AccountsController(TransactionBase):
return stock_items
def set_total_advance_paid(self):
- if self.doctype == "Sales Order":
- dr_or_cr = "credit_in_account_currency"
- rev_dr_or_cr = "debit_in_account_currency"
- party = self.customer
- else:
- dr_or_cr = "debit_in_account_currency"
- rev_dr_or_cr = "credit_in_account_currency"
- party = self.supplier
-
- advance = frappe.db.sql(
- """
- select
- account_currency, sum({dr_or_cr}) - sum({rev_dr_cr}) as amount
- from
- `tabGL Entry`
- where
- against_voucher_type = %s and against_voucher = %s and party=%s
- and docstatus = 1
- """.format(
- dr_or_cr=dr_or_cr, rev_dr_cr=rev_dr_or_cr
- ),
- (self.doctype, self.name, party),
- as_dict=1,
- ) # nosec
+ ple = frappe.qb.DocType("Payment Ledger Entry")
+ party = self.customer if self.doctype == "Sales Order" else self.supplier
+ advance = (
+ frappe.qb.from_(ple)
+ .select(ple.account_currency, Abs(Sum(ple.amount_in_account_currency)).as_("amount"))
+ .where(
+ (ple.against_voucher_type == self.doctype)
+ & (ple.against_voucher_no == self.name)
+ & (ple.party == party)
+ & (ple.docstatus == 1)
+ & (ple.company == self.company)
+ )
+ .run(as_dict=True)
+ )
if advance:
advance = advance[0]
@@ -1379,7 +1415,7 @@ class AccountsController(TransactionBase):
@property
def company_abbr(self):
if not hasattr(self, "_abbr"):
- self._abbr = frappe.db.get_value("Company", self.company, "abbr")
+ self._abbr = frappe.get_cached_value("Company", self.company, "abbr")
return self._abbr
@@ -1765,7 +1801,7 @@ class AccountsController(TransactionBase):
"""
if self.is_internal_transfer() and not self.unrealized_profit_loss_account:
- unrealized_profit_loss_account = frappe.db.get_value(
+ unrealized_profit_loss_account = frappe.get_cached_value(
"Company", self.company, "unrealized_profit_loss_account"
)
@@ -1866,10 +1902,23 @@ class AccountsController(TransactionBase):
):
throw(_("Conversion rate cannot be 0 or 1"))
+ def check_finance_books(self, item, asset):
+ if (
+ len(asset.finance_books) > 1
+ and not item.get("finance_book")
+ and not self.get("finance_book")
+ and asset.finance_books[0].finance_book
+ ):
+ frappe.throw(
+ _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx)
+ )
+
@frappe.whitelist()
def get_tax_rate(account_head):
- return frappe.db.get_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
+ return frappe.get_cached_value(
+ "Account", account_head, ["tax_rate", "account_name"], as_dict=True
+ )
@frappe.whitelist()
@@ -1878,7 +1927,7 @@ def get_default_taxes_and_charges(master_doctype, tax_template=None, company=Non
return {}
if tax_template and company:
- tax_template_company = frappe.db.get_value(master_doctype, tax_template, "company")
+ tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
if tax_template_company == company:
return
@@ -2273,7 +2322,7 @@ def get_due_date(term, posting_date=None, bill_date=None):
elif term.due_date_based_on == "Day(s) after the end of the invoice month":
due_date = add_days(get_last_day(date), term.credit_days)
elif term.due_date_based_on == "Month(s) after the end of the invoice month":
- due_date = add_months(get_last_day(date), term.credit_months)
+ due_date = get_last_day(add_months(date, term.credit_months))
return due_date
@@ -2285,7 +2334,7 @@ def get_discount_date(term, posting_date=None, bill_date=None):
elif term.discount_validity_based_on == "Day(s) after the end of the invoice month":
discount_validity = add_days(get_last_day(date), term.discount_validity)
elif term.discount_validity_based_on == "Month(s) after the end of the invoice month":
- discount_validity = add_months(get_last_day(date), term.discount_validity)
+ discount_validity = get_last_day(add_months(date, term.discount_validity))
return discount_validity
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index 036733c0c3..4f7d9ad92e 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -6,6 +6,7 @@ import frappe
from frappe import ValidationError, _, msgprint
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import cint, cstr, flt, getdate
+from frappe.utils.data import nowtime
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import get_party_details
@@ -24,10 +25,6 @@ class BuyingController(SubcontractingController):
def __setup__(self):
self.flags.ignore_permlevel_for_fields = ["buying_price_list", "price_list_currency"]
- def get_feed(self):
- if self.get("supplier_name"):
- return _("From {0} | {1} {2}").format(self.supplier_name, self.currency, self.grand_total)
-
def validate(self):
super(BuyingController, self).validate()
if getattr(self, "supplier", None) and not self.supplier_name:
@@ -40,6 +37,7 @@ class BuyingController(SubcontractingController):
self.validate_from_warehouse()
self.set_supplier_address()
self.validate_asset_return()
+ self.validate_auto_repeat_subscription_dates()
if self.doctype == "Purchase Invoice":
self.validate_purchase_receipt_if_update_stock()
@@ -86,6 +84,7 @@ class BuyingController(SubcontractingController):
company=self.company,
party_address=self.get("supplier_address"),
shipping_address=self.get("shipping_address"),
+ company_address=self.get("billing_address"),
fetch_payment_terms_template=not self.get("ignore_default_payment_terms_template"),
ignore_permissions=self.flags.ignore_permissions,
)
@@ -192,16 +191,16 @@ class BuyingController(SubcontractingController):
if self.meta.get_field("base_in_words"):
if self.meta.get_field("base_rounded_total") and not self.is_rounded_total_disabled():
- amount = self.base_rounded_total
+ amount = abs(self.base_rounded_total)
else:
- amount = self.base_grand_total
+ amount = abs(self.base_grand_total)
self.base_in_words = money_in_words(amount, self.company_currency)
if self.meta.get_field("in_words"):
if self.meta.get_field("rounded_total") and not self.is_rounded_total_disabled():
- amount = self.rounded_total
+ amount = abs(self.rounded_total)
else:
- amount = self.grand_total
+ amount = abs(self.grand_total)
self.in_words = money_in_words(amount, self.currency)
@@ -275,6 +274,9 @@ class BuyingController(SubcontractingController):
if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Purchase Order"):
return
+ if not self.is_internal_transfer():
+ return
+
ref_doctype_map = {
"Purchase Order": "Sales Order Item",
"Purchase Receipt": "Delivery Note Item",
@@ -288,12 +290,16 @@ class BuyingController(SubcontractingController):
# Get outgoing rate based on original item cost based on valuation method
if not d.get(frappe.scrub(ref_doctype)):
+ posting_time = self.get("posting_time")
+ if not posting_time and self.doctype == "Purchase Order":
+ posting_time = nowtime()
+
outgoing_rate = get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.get("from_warehouse"),
"posting_date": self.get("posting_date") or self.get("transation_date"),
- "posting_time": self.get("posting_time"),
+ "posting_time": posting_time,
"qty": -1 * flt(d.get("stock_qty")),
"serial_no": d.get("serial_no"),
"batch_no": d.get("batch_no"),
@@ -307,20 +313,26 @@ class BuyingController(SubcontractingController):
rate = flt(outgoing_rate * (d.conversion_factor or 1), d.precision("rate"))
else:
- rate = frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), "rate")
+ field = "incoming_rate" if self.get("is_internal_supplier") else "rate"
+ rate = flt(
+ frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), field)
+ * (d.conversion_factor or 1),
+ d.precision("rate"),
+ )
if self.is_internal_transfer():
- if rate != d.rate:
- d.rate = rate
- frappe.msgprint(
- _(
- "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer"
- ).format(d.idx),
- alert=1,
- )
- d.discount_percentage = 0.0
- d.discount_amount = 0.0
- d.margin_rate_or_amount = 0.0
+ if self.doctype == "Purchase Receipt" or self.get("update_stock"):
+ if rate != d.rate:
+ d.rate = rate
+ frappe.msgprint(
+ _(
+ "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer"
+ ).format(d.idx),
+ alert=1,
+ )
+ d.discount_percentage = 0.0
+ d.discount_amount = 0.0
+ d.margin_rate_or_amount = 0.0
def validate_for_subcontracting(self):
if self.is_subcontracted and self.get("is_old_subcontracting_flow"):
@@ -535,7 +547,9 @@ class BuyingController(SubcontractingController):
self.process_fixed_asset()
self.update_fixed_asset(field)
- if self.doctype in ["Purchase Order", "Purchase Receipt"]:
+ if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value(
+ "Buying Settings", "disable_last_purchase_rate"
+ ):
update_last_purchase_rate(self, is_submit=1)
def on_cancel(self):
@@ -544,7 +558,9 @@ class BuyingController(SubcontractingController):
if self.get("is_return"):
return
- if self.doctype in ["Purchase Order", "Purchase Receipt"]:
+ if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value(
+ "Buying Settings", "disable_last_purchase_rate"
+ ):
update_last_purchase_rate(self, is_submit=0)
if self.doctype in ["Purchase Receipt", "Purchase Invoice"]:
@@ -696,6 +712,8 @@ class BuyingController(SubcontractingController):
asset.purchase_date = self.posting_date
asset.supplier = self.supplier
elif self.docstatus == 2:
+ if asset.docstatus == 2:
+ continue
if asset.docstatus == 0:
asset.set(field, None)
asset.supplier = None
diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py
deleted file mode 100644
index c06fb5930d..0000000000
--- a/erpnext/controllers/employee_boarding_controller.py
+++ /dev/null
@@ -1,193 +0,0 @@
-# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-from frappe import _
-from frappe.desk.form import assign_to
-from frappe.model.document import Document
-from frappe.utils import add_days, flt, unique
-
-from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee
-from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
-
-
-class EmployeeBoardingController(Document):
- """
- Create the project and the task for the boarding process
- Assign to the concerned person and roles as per the onboarding/separation template
- """
-
- def validate(self):
- # remove the task if linked before submitting the form
- if self.amended_from:
- for activity in self.activities:
- activity.task = ""
-
- def on_submit(self):
- # create the project for the given employee onboarding
- project_name = _(self.doctype) + " : "
- if self.doctype == "Employee Onboarding":
- project_name += self.job_applicant
- else:
- project_name += self.employee
-
- project = frappe.get_doc(
- {
- "doctype": "Project",
- "project_name": project_name,
- "expected_start_date": self.date_of_joining
- if self.doctype == "Employee Onboarding"
- else self.resignation_letter_date,
- "department": self.department,
- "company": self.company,
- }
- ).insert(ignore_permissions=True, ignore_mandatory=True)
-
- self.db_set("project", project.name)
- self.db_set("boarding_status", "Pending")
- self.reload()
- self.create_task_and_notify_user()
-
- def create_task_and_notify_user(self):
- # create the task for the given project and assign to the concerned person
- holiday_list = self.get_holiday_list()
-
- for activity in self.activities:
- if activity.task:
- continue
-
- dates = self.get_task_dates(activity, holiday_list)
-
- task = frappe.get_doc(
- {
- "doctype": "Task",
- "project": self.project,
- "subject": activity.activity_name + " : " + self.employee_name,
- "description": activity.description,
- "department": self.department,
- "company": self.company,
- "task_weight": activity.task_weight,
- "exp_start_date": dates[0],
- "exp_end_date": dates[1],
- }
- ).insert(ignore_permissions=True)
- activity.db_set("task", task.name)
-
- users = [activity.user] if activity.user else []
- if activity.role:
- user_list = frappe.db.sql_list(
- """
- SELECT
- DISTINCT(has_role.parent)
- FROM
- `tabHas Role` has_role
- LEFT JOIN `tabUser` user
- ON has_role.parent = user.name
- WHERE
- has_role.parenttype = 'User'
- AND user.enabled = 1
- AND has_role.role = %s
- """,
- activity.role,
- )
- users = unique(users + user_list)
-
- if "Administrator" in users:
- users.remove("Administrator")
-
- # assign the task the users
- if users:
- self.assign_task_to_users(task, users)
-
- def get_holiday_list(self):
- if self.doctype == "Employee Separation":
- return get_holiday_list_for_employee(self.employee)
- else:
- if self.employee:
- return get_holiday_list_for_employee(self.employee)
- else:
- if not self.holiday_list:
- frappe.throw(_("Please set the Holiday List."), frappe.MandatoryError)
- else:
- return self.holiday_list
-
- def get_task_dates(self, activity, holiday_list):
- start_date = end_date = None
-
- if activity.begin_on is not None:
- start_date = add_days(self.boarding_begins_on, activity.begin_on)
- start_date = self.update_if_holiday(start_date, holiday_list)
-
- if activity.duration is not None:
- end_date = add_days(self.boarding_begins_on, activity.begin_on + activity.duration)
- end_date = self.update_if_holiday(end_date, holiday_list)
-
- return [start_date, end_date]
-
- def update_if_holiday(self, date, holiday_list):
- while is_holiday(holiday_list, date):
- date = add_days(date, 1)
- return date
-
- def assign_task_to_users(self, task, users):
- for user in users:
- args = {
- "assign_to": [user],
- "doctype": task.doctype,
- "name": task.name,
- "description": task.description or task.subject,
- "notify": self.notify_users_by_email,
- }
- assign_to.add(args)
-
- def on_cancel(self):
- # delete task project
- project = self.project
- for task in frappe.get_all("Task", filters={"project": project}):
- frappe.delete_doc("Task", task.name, force=1)
- frappe.delete_doc("Project", project, force=1)
- self.db_set("project", "")
- for activity in self.activities:
- activity.db_set("task", "")
-
- frappe.msgprint(
- _("Linked Project {} and Tasks deleted.").format(project), alert=True, indicator="blue"
- )
-
-
-@frappe.whitelist()
-def get_onboarding_details(parent, parenttype):
- return frappe.get_all(
- "Employee Boarding Activity",
- fields=[
- "activity_name",
- "role",
- "user",
- "required_for_employee_creation",
- "description",
- "task_weight",
- "begin_on",
- "duration",
- ],
- filters={"parent": parent, "parenttype": parenttype},
- order_by="idx",
- )
-
-
-def update_employee_boarding_status(project):
- employee_onboarding = frappe.db.exists("Employee Onboarding", {"project": project.name})
- employee_separation = frappe.db.exists("Employee Separation", {"project": project.name})
-
- if not (employee_onboarding or employee_separation):
- return
-
- status = "Pending"
- if flt(project.percent_complete) > 0.0 and flt(project.percent_complete) < 100.0:
- status = "In Process"
- elif flt(project.percent_complete) == 100.0:
- status = "Completed"
-
- if employee_onboarding:
- frappe.db.set_value("Employee Onboarding", employee_onboarding, "boarding_status", status)
- elif employee_separation:
- frappe.db.set_value("Employee Separation", employee_separation, "boarding_status", status)
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index 243ebb66e2..b0cf724166 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -18,8 +18,9 @@ from erpnext.stock.get_item_details import _get_item_tax_template
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def employee_query(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "Employee"
conditions = []
- fields = get_fields("Employee", ["name", "employee_name"])
+ fields = get_fields(doctype, ["name", "employee_name"])
return frappe.db.sql(
"""select {fields} from `tabEmployee`
@@ -49,7 +50,8 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def lead_query(doctype, txt, searchfield, start, page_len, filters):
- fields = get_fields("Lead", ["name", "lead_name", "company_name"])
+ doctype = "Lead"
+ fields = get_fields(doctype, ["name", "lead_name", "company_name"])
return frappe.db.sql(
"""select {fields} from `tabLead`
@@ -76,18 +78,17 @@ def lead_query(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
-def customer_query(doctype, txt, searchfield, start, page_len, filters):
+def customer_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
+ doctype = "Customer"
conditions = []
cust_master_name = frappe.defaults.get_user_default("cust_master_name")
- if cust_master_name == "Customer Name":
- fields = ["name", "customer_group", "territory"]
- else:
- fields = ["name", "customer_name", "customer_group", "territory"]
+ fields = ["name"]
+ if cust_master_name != "Customer Name":
+ fields.append("customer_name")
- fields = get_fields("Customer", fields)
-
- searchfields = frappe.get_meta("Customer").get_search_fields()
+ fields = get_fields(doctype, fields)
+ searchfields = frappe.get_meta(doctype).get_search_fields()
searchfields = " or ".join(field + " like %(txt)s" for field in searchfields)
return frappe.db.sql(
@@ -109,21 +110,22 @@ def customer_query(doctype, txt, searchfield, start, page_len, filters):
}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
+ as_dict=as_dict,
)
# searches for supplier
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
-def supplier_query(doctype, txt, searchfield, start, page_len, filters):
+def supplier_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
+ doctype = "Supplier"
supp_master_name = frappe.defaults.get_user_default("supp_master_name")
- if supp_master_name == "Supplier Name":
- fields = ["name", "supplier_group"]
- else:
- fields = ["name", "supplier_name", "supplier_group"]
+ fields = ["name"]
+ if supp_master_name != "Supplier Name":
+ fields.append("supplier_name")
- fields = get_fields("Supplier", fields)
+ fields = get_fields(doctype, fields)
return frappe.db.sql(
"""select {field} from `tabSupplier`
@@ -141,12 +143,14 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters):
**{"field": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
+ as_dict=as_dict,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def tax_account_query(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "Account"
company_currency = erpnext.get_company_currency(filters.get("company"))
def get_accounts(with_account_type_filter):
@@ -197,30 +201,25 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
+ doctype = "Item"
conditions = []
if isinstance(filters, str):
filters = json.loads(filters)
# Get searchfields from meta and use in Item Link field query
- meta = frappe.get_meta("Item", cached=True)
+ meta = frappe.get_meta(doctype, cached=True)
searchfields = meta.get_search_fields()
- # these are handled separately
- ignored_search_fields = ("item_name", "description")
- for ignored_field in ignored_search_fields:
- if ignored_field in searchfields:
- searchfields.remove(ignored_field)
-
columns = ""
- extra_searchfields = [
- field
- for field in searchfields
- if not field in ["name", "item_group", "description", "item_name"]
- ]
+ extra_searchfields = [field for field in searchfields if not field in ["name", "description"]]
if extra_searchfields:
- columns = ", " + ", ".join(extra_searchfields)
+ columns += ", " + ", ".join(extra_searchfields)
+
+ if "description" in searchfields:
+ columns += """, if(length(tabItem.description) > 40, \
+ concat(substr(tabItem.description, 1, 40), "..."), description) as description"""
searchfields = searchfields + [
field
@@ -257,15 +256,13 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
filters.pop("supplier", None)
description_cond = ""
- if frappe.db.count("Item", cache=True) < 50000:
+ if frappe.db.count(doctype, cache=True) < 50000:
# scan description only if items are less than 50000
description_cond = "or tabItem.description LIKE %(txt)s"
+
return frappe.db.sql(
"""select
- tabItem.name, tabItem.item_name, tabItem.item_group,
- if(length(tabItem.description) > 40, \
- concat(substr(tabItem.description, 1, 40), "..."), description) as description
- {columns}
+ tabItem.name {columns}
from tabItem
where tabItem.docstatus < 2
and tabItem.disabled=0
@@ -300,8 +297,9 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def bom(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "BOM"
conditions = []
- fields = get_fields("BOM", ["name", "item"])
+ fields = get_fields(doctype, ["name", "item"])
return frappe.db.sql(
"""select {fields}
@@ -331,6 +329,7 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "Project"
cond = ""
if filters and filters.get("customer"):
cond = """(`tabProject`.customer = %s or
@@ -338,8 +337,8 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
frappe.db.escape(filters.get("customer"))
)
- fields = get_fields("Project", ["name", "project_name"])
- searchfields = frappe.get_meta("Project").get_search_fields()
+ fields = get_fields(doctype, ["name", "project_name"])
+ searchfields = frappe.get_meta(doctype).get_search_fields()
searchfields = " or ".join(["`tabProject`." + field + " like %(txt)s" for field in searchfields])
return frappe.db.sql(
@@ -366,7 +365,8 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, filters, as_dict):
- fields = get_fields("Delivery Note", ["name", "customer", "posting_date"])
+ doctype = "Delivery Note"
+ fields = get_fields(doctype, ["name", "customer", "posting_date"])
return frappe.db.sql(
"""
@@ -402,6 +402,7 @@ def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len,
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_batch_no(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "Batch"
cond = ""
if filters.get("posting_date"):
cond = "and (batch.expiry_date is null or batch.expiry_date >= %(posting_date)s)"
@@ -420,7 +421,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters):
if filters.get("is_return"):
having_clause = ""
- meta = frappe.get_meta("Batch", cached=True)
+ meta = frappe.get_meta(doctype, cached=True)
searchfields = meta.get_search_fields()
search_columns = ""
@@ -496,6 +497,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_account_list(doctype, txt, searchfield, start, page_len, filters):
+ doctype = "Account"
filter_list = []
if isinstance(filters, dict):
@@ -514,7 +516,7 @@ def get_account_list(doctype, txt, searchfield, start, page_len, filters):
filter_list.append([doctype, searchfield, "like", "%%%s%%" % txt])
return frappe.desk.reportview.execute(
- "Account",
+ doctype,
filters=filter_list,
fields=["name", "parent_account"],
limit_start=start,
@@ -553,6 +555,7 @@ def get_income_account(doctype, txt, searchfield, start, page_len, filters):
if not filters:
filters = {}
+ doctype = "Account"
condition = ""
if filters.get("company"):
condition += "and tabAccount.company = %(company)s"
@@ -628,6 +631,7 @@ def get_expense_account(doctype, txt, searchfield, start, page_len, filters):
if not filters:
filters = {}
+ doctype = "Account"
condition = ""
if filters.get("company"):
condition += "and tabAccount.company = %(company)s"
@@ -650,6 +654,7 @@ def get_expense_account(doctype, txt, searchfield, start, page_len, filters):
@frappe.validate_and_sanitize_search_inputs
def warehouse_query(doctype, txt, searchfield, start, page_len, filters):
# Should be used when item code is passed in filters.
+ doctype = "Warehouse"
conditions, bin_conditions = [], []
filter_dict = get_doctype_wise_filters(filters)
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index 04a0dfa3d4..9fcb769bc8 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -37,7 +37,7 @@ def validate_return_against(doc):
if (
ref_doc.company == doc.company
and ref_doc.get(party_type) == doc.get(party_type)
- and ref_doc.docstatus == 1
+ and ref_doc.docstatus.is_submitted()
):
# validate posting date time
return_posting_datetime = "%s %s" % (doc.posting_date, doc.get("posting_time") or "00:00:00")
@@ -305,7 +305,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
fields += ["sum(abs(`tab{0}`.received_stock_qty)) as received_stock_qty".format(child_doctype)]
# Used retrun against and supplier and is_retrun because there is an index added for it
- data = frappe.db.get_list(
+ data = frappe.get_all(
doctype,
fields=fields,
filters=[
@@ -326,7 +326,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
company = frappe.db.get_value("Delivery Note", source_name, "company")
- default_warehouse_for_sales_return = frappe.db.get_value(
+ default_warehouse_for_sales_return = frappe.get_cached_value(
"Company", company, "default_warehouse_for_sales_return"
)
@@ -340,11 +340,11 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
# look for Print Heading "Credit Note"
if not doc.select_print_heading:
- doc.select_print_heading = frappe.db.get_value("Print Heading", _("Credit Note"))
+ doc.select_print_heading = frappe.get_cached_value("Print Heading", _("Credit Note"))
elif doctype == "Purchase Invoice":
# look for Print Heading "Debit Note"
- doc.select_print_heading = frappe.db.get_value("Print Heading", _("Debit Note"))
+ doc.select_print_heading = frappe.get_cached_value("Print Heading", _("Debit Note"))
for tax in doc.get("taxes") or []:
if tax.charge_type == "Actual":
@@ -404,12 +404,17 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
returned_qty_map = get_returned_qty_map_for_row(
source_parent.name, source_parent.supplier, source_doc.name, doctype
)
- target_doc.received_qty = -1 * flt(
- source_doc.received_qty - (returned_qty_map.get("received_qty") or 0)
- )
- target_doc.rejected_qty = -1 * flt(
- source_doc.rejected_qty - (returned_qty_map.get("rejected_qty") or 0)
- )
+
+ if doctype == "Subcontracting Receipt":
+ target_doc.received_qty = -1 * flt(source_doc.qty)
+ else:
+ target_doc.received_qty = -1 * flt(
+ source_doc.received_qty - (returned_qty_map.get("received_qty") or 0)
+ )
+ target_doc.rejected_qty = -1 * flt(
+ source_doc.rejected_qty - (returned_qty_map.get("rejected_qty") or 0)
+ )
+
target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get("qty") or 0))
if hasattr(target_doc, "stock_qty"):
@@ -503,7 +508,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
doctype
+ " Item": {
"doctype": doctype + " Item",
- "field_map": {"serial_no": "serial_no", "batch_no": "batch_no"},
+ "field_map": {"serial_no": "serial_no", "batch_no": "batch_no", "bom": "bom"},
"postprocess": update_item,
},
"Payment Schedule": {"doctype": "Payment Schedule", "postprocess": update_terms},
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index a3d41ab29a..8b4d28bc7d 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -19,14 +19,11 @@ class SellingController(StockController):
def __setup__(self):
self.flags.ignore_permlevel_for_fields = ["selling_price_list", "price_list_currency"]
- def get_feed(self):
- return _("To {0} | {1} {2}").format(self.customer_name, self.currency, self.grand_total)
-
def onload(self):
super(SellingController, self).onload()
if self.doctype in ("Sales Order", "Delivery Note", "Sales Invoice"):
- for item in self.get("items"):
- item.update(get_bin_details(item.item_code, item.warehouse))
+ for item in self.get("items") + (self.get("packed_items") or []):
+ item.update(get_bin_details(item.item_code, item.warehouse, include_child_warehouses=True))
def validate(self):
super(SellingController, self).validate()
@@ -40,6 +37,7 @@ class SellingController(StockController):
self.set_customer_address()
self.validate_for_duplicate_items()
self.validate_target_warehouse()
+ self.validate_auto_repeat_subscription_dates()
def set_missing_values(self, for_validate=False):
@@ -311,6 +309,7 @@ class SellingController(StockController):
"sales_invoice_item": d.get("sales_invoice_item"),
"dn_detail": d.get("dn_detail"),
"incoming_rate": p.get("incoming_rate"),
+ "item_row": p,
}
)
)
@@ -334,6 +333,7 @@ class SellingController(StockController):
"sales_invoice_item": d.get("sales_invoice_item"),
"dn_detail": d.get("dn_detail"),
"incoming_rate": d.get("incoming_rate"),
+ "item_row": d,
}
)
)
@@ -439,24 +439,31 @@ class SellingController(StockController):
# For internal transfers use incoming rate as the valuation rate
if self.is_internal_transfer():
- if d.doctype == "Packed Item":
- incoming_rate = flt(d.incoming_rate * d.conversion_factor, d.precision("incoming_rate"))
- if d.incoming_rate != incoming_rate:
- d.incoming_rate = incoming_rate
- else:
- rate = flt(d.incoming_rate * d.conversion_factor, d.precision("rate"))
- if d.rate != rate:
- d.rate = rate
- frappe.msgprint(
- _(
- "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer"
- ).format(d.idx),
- alert=1,
+ if self.doctype == "Delivery Note" or self.get("update_stock"):
+ if d.doctype == "Packed Item":
+ incoming_rate = flt(
+ flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
+ d.precision("incoming_rate"),
)
+ if d.incoming_rate != incoming_rate:
+ d.incoming_rate = incoming_rate
+ else:
+ rate = flt(
+ flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
+ d.precision("rate"),
+ )
+ if d.rate != rate:
+ d.rate = rate
+ frappe.msgprint(
+ _(
+ "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer"
+ ).format(d.idx),
+ alert=1,
+ )
- d.discount_percentage = 0.0
- d.discount_amount = 0.0
- d.margin_rate_or_amount = 0.0
+ d.discount_percentage = 0.0
+ d.discount_amount = 0.0
+ d.margin_rate_or_amount = 0.0
elif self.get("return_against"):
# Get incoming rate of return entry from reference document
@@ -573,6 +580,7 @@ class SellingController(StockController):
"customer_address": "address_display",
"shipping_address_name": "shipping_address",
"company_address": "company_address_display",
+ "dispatch_address_name": "dispatch_address",
}
for address_field, address_display_field in address_dict.items():
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 197d2ba2dc..dd2a67032f 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -58,7 +58,7 @@ status_map = {
"eval:(self.per_delivered == 100 or self.skip_delivery_note) and self.per_billed == 100 and self.docstatus == 1",
],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
["On Hold", "eval:self.status=='On Hold'"],
],
"Purchase Order": [
@@ -79,7 +79,7 @@ status_map = {
["Delivered", "eval:self.status=='Delivered'"],
["Cancelled", "eval:self.docstatus==2"],
["On Hold", "eval:self.status=='On Hold'"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Delivery Note": [
["Draft", None],
@@ -87,7 +87,7 @@ status_map = {
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Purchase Receipt": [
["Draft", None],
@@ -95,7 +95,7 @@ status_map = {
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Material Request": [
["Draft", None],
@@ -307,6 +307,20 @@ class StatusUpdater(Document):
def limits_crossed_error(self, args, item, qty_or_amount):
"""Raise exception for limits crossed"""
+ if (
+ self.doctype in ["Sales Invoice", "Delivery Note"]
+ and qty_or_amount == "amount"
+ and self.is_internal_customer
+ ):
+ return
+
+ elif (
+ self.doctype in ["Purchase Invoice", "Purchase Receipt"]
+ and qty_or_amount == "amount"
+ and self.is_internal_supplier
+ ):
+ return
+
if qty_or_amount == "qty":
action_msg = _(
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
@@ -333,16 +347,21 @@ class StatusUpdater(Document):
)
def warn_about_bypassing_with_role(self, item, qty_or_amount, role):
- action = _("Over Receipt/Delivery") if qty_or_amount == "qty" else _("Overbilling")
+ if qty_or_amount == "qty":
+ msg = _("Over Receipt/Delivery of {0} {1} ignored for item {2} because you have {3} role.")
+ else:
+ msg = _("Overbilling of {0} {1} ignored for item {2} because you have {3} role.")
- msg = _("{} of {} {} ignored for item {} because you have {} role.").format(
- action,
- _(item["target_ref_field"].title()),
- frappe.bold(item["reduce_by"]),
- frappe.bold(item.get("item_code")),
- role,
+ frappe.msgprint(
+ msg.format(
+ _(item["target_ref_field"].title()),
+ frappe.bold(item["reduce_by"]),
+ frappe.bold(item.get("item_code")),
+ role,
+ ),
+ indicator="orange",
+ alert=True,
)
- frappe.msgprint(msg, indicator="orange", alert=True)
def update_qty(self, update_modified=True):
"""Updates qty or amount at row level
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index e27718a9b4..1e4fabe0d2 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -36,6 +36,10 @@ class QualityInspectionNotSubmittedError(frappe.ValidationError):
pass
+class BatchExpiredError(frappe.ValidationError):
+ pass
+
+
class StockController(AccountsController):
def validate(self):
super(StockController, self).validate()
@@ -53,7 +57,7 @@ class StockController(AccountsController):
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
provisional_accounting_for_non_stock_items = cint(
- frappe.db.get_value(
+ frappe.get_cached_value(
"Company", self.company, "enable_provisional_accounting_for_non_stock_items"
)
)
@@ -77,6 +81,10 @@ class StockController(AccountsController):
def validate_serialized_batch(self):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+ is_material_issue = False
+ if self.doctype == "Stock Entry" and self.purpose == "Material Issue":
+ is_material_issue = True
+
for d in self.get("items"):
if hasattr(d, "serial_no") and hasattr(d, "batch_no") and d.serial_no and d.batch_no:
serial_nos = frappe.get_all(
@@ -93,6 +101,9 @@ class StockController(AccountsController):
)
)
+ if is_material_issue:
+ continue
+
if flt(d.qty) > 0.0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2:
expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date")
@@ -100,7 +111,8 @@ class StockController(AccountsController):
frappe.throw(
_("Row #{0}: The batch {1} has already expired.").format(
d.idx, get_link_to_form("Batch", d.get("batch_no"))
- )
+ ),
+ BatchExpiredError,
)
def clean_serial_nos(self):
@@ -130,13 +142,15 @@ class StockController(AccountsController):
warehouse_with_no_account = []
precision = self.get_debit_field_precision()
for item_row in voucher_details:
-
sle_list = sle_map.get(item_row.name)
+ sle_rounding_diff = 0.0
if sle_list:
for sle in sle_list:
if warehouse_account.get(sle.warehouse):
# from warehouse account
+ sle_rounding_diff += flt(sle.stock_value_difference)
+
self.check_expense_account(item_row)
# expense account/ target_warehouse / source_warehouse
@@ -179,9 +193,49 @@ class StockController(AccountsController):
elif sle.warehouse not in warehouse_with_no_account:
warehouse_with_no_account.append(sle.warehouse)
+ if abs(sle_rounding_diff) > (1.0 / (10**precision)) and self.is_internal_transfer():
+ warehouse_asset_account = ""
+ if self.get("is_internal_customer"):
+ warehouse_asset_account = warehouse_account[item_row.get("target_warehouse")]["account"]
+ elif self.get("is_internal_supplier"):
+ warehouse_asset_account = warehouse_account[item_row.get("warehouse")]["account"]
+
+ expense_account = frappe.get_cached_value("Company", self.company, "default_expense_account")
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": warehouse_asset_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.project or self.get("project"),
+ "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
+ "debit": sle_rounding_diff,
+ "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
+ },
+ warehouse_account[sle.warehouse]["account_currency"],
+ item=item_row,
+ )
+ )
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": warehouse_asset_account,
+ "against": expense_account,
+ "cost_center": item_row.cost_center,
+ "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
+ "credit": sle_rounding_diff,
+ "project": item_row.get("project") or self.get("project"),
+ "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
+ },
+ item=item_row,
+ )
+ )
+
if warehouse_with_no_account:
for wh in warehouse_with_no_account:
- if frappe.db.get_value("Warehouse", wh, "company"):
+ if frappe.get_cached_value("Warehouse", wh, "company"):
frappe.throw(
_(
"Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}."
@@ -310,7 +364,13 @@ class StockController(AccountsController):
)
if (
self.doctype
- not in ("Purchase Receipt", "Purchase Invoice", "Stock Reconciliation", "Stock Entry")
+ not in (
+ "Purchase Receipt",
+ "Purchase Invoice",
+ "Stock Reconciliation",
+ "Stock Entry",
+ "Subcontracting Receipt",
+ )
and not is_expense_account
):
frappe.throw(
@@ -372,11 +432,38 @@ class StockController(AccountsController):
return sl_dict
def update_inventory_dimensions(self, row, sl_dict) -> None:
+ # To handle delivery note and sales invoice
+ if row.get("item_row"):
+ row = row.get("item_row")
+
dimensions = get_evaluated_inventory_dimension(row, sl_dict, parent_doc=self)
for dimension in dimensions:
- if dimension and row.get(dimension.source_fieldname):
+ if not dimension:
+ continue
+
+ if row.get(dimension.source_fieldname):
sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname)
+ if not sl_dict.get(dimension.target_fieldname) and dimension.fetch_from_parent:
+ sl_dict[dimension.target_fieldname] = self.get(dimension.fetch_from_parent)
+
+ # Get value based on doctype name
+ if not sl_dict.get(dimension.target_fieldname):
+ fieldname = next(
+ (
+ field.fieldname
+ for field in frappe.get_meta(self.doctype).fields
+ if field.options == dimension.fetch_from_parent
+ ),
+ None,
+ )
+
+ if fieldname and self.get(fieldname):
+ sl_dict[dimension.target_fieldname] = self.get(fieldname)
+
+ if sl_dict[dimension.target_fieldname] and self.docstatus == 1:
+ row.db_set(dimension.source_fieldname, sl_dict[dimension.target_fieldname])
+
def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
from erpnext.stock.stock_ledger import make_sl_entries
@@ -655,6 +742,47 @@ class StockController(AccountsController):
else:
create_repost_item_valuation_entry(args)
+ def add_gl_entry(
+ self,
+ gl_entries,
+ account,
+ cost_center,
+ debit,
+ credit,
+ remarks,
+ against_account,
+ debit_in_account_currency=None,
+ credit_in_account_currency=None,
+ account_currency=None,
+ project=None,
+ voucher_detail_no=None,
+ item=None,
+ posting_date=None,
+ ):
+
+ gl_entry = {
+ "account": account,
+ "cost_center": cost_center,
+ "debit": debit,
+ "credit": credit,
+ "against": against_account,
+ "remarks": remarks,
+ }
+
+ if voucher_detail_no:
+ gl_entry.update({"voucher_detail_no": voucher_detail_no})
+
+ if debit_in_account_currency:
+ gl_entry.update({"debit_in_account_currency": debit_in_account_currency})
+
+ if credit_in_account_currency:
+ gl_entry.update({"credit_in_account_currency": credit_in_account_currency})
+
+ if posting_date:
+ gl_entry.update({"posting_date": posting_date})
+
+ gl_entries.append(self.get_gl_dict(gl_entry, item=item))
+
def repost_required_for_queue(doc: StockController) -> bool:
"""check if stock document contains repeated item-warehouse with queue based valuation.
diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py
index 2a2f8f562e..cc80f6ca98 100644
--- a/erpnext/controllers/subcontracting_controller.py
+++ b/erpnext/controllers/subcontracting_controller.py
@@ -7,6 +7,7 @@ from collections import defaultdict
import frappe
from frappe import _
+from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, cstr, flt, get_link_to_form
from erpnext.controllers.stock_controller import StockController
@@ -68,17 +69,30 @@ class SubcontractingController(StockController):
def validate_items(self):
for item in self.items:
- if not frappe.get_value("Item", item.item_code, "is_sub_contracted_item"):
- msg = f"Item {item.item_name} must be a subcontracted item."
- frappe.throw(_(msg))
+ is_stock_item, is_sub_contracted_item = frappe.get_value(
+ "Item", item.item_code, ["is_stock_item", "is_sub_contracted_item"]
+ )
+
+ if not is_stock_item:
+ frappe.throw(_("Row {0}: Item {1} must be a stock item.").format(item.idx, item.item_name))
+
+ if not is_sub_contracted_item:
+ frappe.throw(
+ _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
+ )
+
if item.bom:
bom = frappe.get_doc("BOM", item.bom)
if not bom.is_active:
- msg = f"Please select an active BOM for Item {item.item_name}."
- frappe.throw(_(msg))
+ frappe.throw(
+ _("Row {0}: Please select an active BOM for Item {1}.").format(item.idx, item.item_name)
+ )
if bom.item != item.item_code:
- msg = f"Please select an valid BOM for Item {item.item_name}."
- frappe.throw(_(msg))
+ frappe.throw(
+ _("Row {0}: Please select an valid BOM for Item {1}.").format(item.idx, item.item_name)
+ )
+ else:
+ frappe.throw(_("Row {0}: Please select a BOM for Item {1}.").format(item.idx, item.item_name))
def __get_data_before_save(self):
item_dict = {}
@@ -395,7 +409,14 @@ class SubcontractingController(StockController):
if self.available_materials.get(key) and self.available_materials[key]["batch_no"]:
new_rm_obj = None
for batch_no, batch_qty in self.available_materials[key]["batch_no"].items():
- if batch_qty >= qty:
+ if batch_qty >= qty or (
+ rm_obj.consumed_qty == 0
+ and self.backflush_based_on == "BOM"
+ and len(self.available_materials[key]["batch_no"]) == 1
+ ):
+ if rm_obj.consumed_qty == 0:
+ self.__set_consumed_qty(rm_obj, qty)
+
self.__set_batch_no_as_per_qty(item_row, rm_obj, batch_no, qty)
self.available_materials[key]["batch_no"][batch_no] -= qty
return
@@ -490,7 +511,7 @@ class SubcontractingController(StockController):
row.item_code,
row.get(self.subcontract_data.order_field),
) and transfer_item.qty > 0:
- qty = self.__get_qty_based_on_material_transfer(row, transfer_item) or 0
+ qty = flt(self.__get_qty_based_on_material_transfer(row, transfer_item))
transfer_item.qty -= qty
self.__add_supplied_item(row, transfer_item.get("item_details"), qty)
@@ -720,6 +741,25 @@ class SubcontractingController(StockController):
sco_doc = frappe.get_doc("Subcontracting Order", sco)
sco_doc.update_status()
+ def set_missing_values_in_additional_costs(self):
+ self.total_additional_costs = sum(flt(item.amount) for item in self.get("additional_costs"))
+
+ if self.total_additional_costs:
+ if self.distribute_additional_costs_based_on == "Amount":
+ total_amt = sum(flt(item.amount) for item in self.get("items"))
+ for item in self.items:
+ item.additional_cost_per_qty = (
+ (item.amount * self.total_additional_costs) / total_amt
+ ) / item.qty
+ else:
+ total_qty = sum(flt(item.qty) for item in self.get("items"))
+ additional_cost_per_qty = self.total_additional_costs / total_qty
+ for item in self.items:
+ item.additional_cost_per_qty = additional_cost_per_qty
+ else:
+ for item in self.items:
+ item.additional_cost_per_qty = 0
+
@frappe.whitelist()
def get_current_stock(self):
if self.doctype in ["Purchase Receipt", "Subcontracting Receipt"]:
@@ -730,7 +770,7 @@ class SubcontractingController(StockController):
{"item_code": item.rm_item_code, "warehouse": self.supplier_warehouse},
"actual_qty",
)
- item.current_stock = flt(actual_qty) or 0
+ item.current_stock = flt(actual_qty)
@property
def sub_contracted_items(self):
@@ -750,7 +790,7 @@ def get_item_details(items):
item = frappe.qb.DocType("Item")
item_list = (
frappe.qb.from_(item)
- .select(item.item_code, item.description, item.allow_alternative_item)
+ .select(item.item_code, item.item_name, item.description, item.allow_alternative_item)
.where(item.name.isin(items))
.run(as_dict=True)
)
@@ -763,68 +803,96 @@ def get_item_details(items):
@frappe.whitelist()
-def make_rm_stock_entry(subcontract_order, rm_items, order_doctype="Subcontracting Order"):
- rm_items_list = rm_items
-
- if isinstance(rm_items, str):
- rm_items_list = json.loads(rm_items)
- elif not rm_items:
- frappe.throw(_("No Items available for transfer"))
-
- if rm_items_list:
- fg_items = list(set(item["item_code"] for item in rm_items_list))
- else:
- frappe.throw(_("No Items selected for transfer"))
-
+def make_rm_stock_entry(
+ subcontract_order, rm_items=None, order_doctype="Subcontracting Order", target_doc=None
+):
if subcontract_order:
subcontract_order = frappe.get_doc(order_doctype, subcontract_order)
- if fg_items:
- items = tuple(set(item["rm_item_code"] for item in rm_items_list))
- item_wh = get_item_details(items)
+ if not rm_items:
+ if not subcontract_order.supplied_items:
+ frappe.throw(_("No item available for transfer."))
- stock_entry = frappe.new_doc("Stock Entry")
- stock_entry.purpose = "Send to Subcontractor"
- if order_doctype == "Purchase Order":
- stock_entry.purchase_order = subcontract_order.name
- else:
- stock_entry.subcontracting_order = subcontract_order.name
- stock_entry.supplier = subcontract_order.supplier
- stock_entry.supplier_name = subcontract_order.supplier_name
- stock_entry.supplier_address = subcontract_order.supplier_address
- stock_entry.address_display = subcontract_order.address_display
- stock_entry.company = subcontract_order.company
- stock_entry.to_warehouse = subcontract_order.supplier_warehouse
- stock_entry.set_stock_entry_type()
+ rm_items = subcontract_order.supplied_items
- if order_doctype == "Purchase Order":
- rm_detail_field = "po_detail"
- else:
- rm_detail_field = "sco_rm_detail"
+ fg_item_code_list = list(
+ set(item.get("main_item_code") or item.get("item_code") for item in rm_items)
+ )
- for item_code in fg_items:
- for rm_item_data in rm_items_list:
- if rm_item_data["item_code"] == item_code:
- rm_item_code = rm_item_data["rm_item_code"]
- items_dict = {
- rm_item_code: {
- rm_detail_field: rm_item_data.get("name"),
- "item_name": rm_item_data["item_name"],
- "description": item_wh.get(rm_item_code, {}).get("description", ""),
- "qty": rm_item_data["qty"],
- "from_warehouse": rm_item_data["warehouse"],
- "stock_uom": rm_item_data["stock_uom"],
- "serial_no": rm_item_data.get("serial_no"),
- "batch_no": rm_item_data.get("batch_no"),
- "main_item_code": rm_item_data["item_code"],
- "allow_alternative_item": item_wh.get(rm_item_code, {}).get("allow_alternative_item"),
+ if fg_item_code_list:
+ rm_item_code_list = tuple(set(item.get("rm_item_code") for item in rm_items))
+ item_wh = get_item_details(rm_item_code_list)
+
+ field_no_map, rm_detail_field = "purchase_order", "sco_rm_detail"
+ if order_doctype == "Purchase Order":
+ field_no_map, rm_detail_field = "subcontracting_order", "po_detail"
+
+ if target_doc and target_doc.get("items"):
+ target_doc.items = []
+
+ stock_entry = get_mapped_doc(
+ order_doctype,
+ subcontract_order.name,
+ {
+ order_doctype: {
+ "doctype": "Stock Entry",
+ "field_map": {
+ "supplier": "supplier",
+ "supplier_name": "supplier_name",
+ "supplier_address": "supplier_address",
+ "to_warehouse": "supplier_warehouse",
+ },
+ "field_no_map": [field_no_map],
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ },
+ target_doc,
+ ignore_child_tables=True,
+ )
+
+ stock_entry.purpose = "Send to Subcontractor"
+
+ if order_doctype == "Purchase Order":
+ stock_entry.purchase_order = subcontract_order.name
+ else:
+ stock_entry.subcontracting_order = subcontract_order.name
+
+ stock_entry.set_stock_entry_type()
+
+ for fg_item_code in fg_item_code_list:
+ for rm_item in rm_items:
+
+ if rm_item.get("main_item_code") == fg_item_code or rm_item.get("item_code") == fg_item_code:
+ rm_item_code = rm_item.get("rm_item_code")
+
+ items_dict = {
+ rm_item_code: {
+ rm_detail_field: rm_item.get("name"),
+ "item_name": rm_item.get("item_name")
+ or item_wh.get(rm_item_code, {}).get("item_name", ""),
+ "description": item_wh.get(rm_item_code, {}).get("description", ""),
+ "qty": rm_item.get("qty")
+ or max(rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0),
+ "from_warehouse": rm_item.get("warehouse") or rm_item.get("reserve_warehouse"),
+ "to_warehouse": subcontract_order.supplier_warehouse,
+ "stock_uom": rm_item.get("stock_uom"),
+ "serial_no": rm_item.get("serial_no"),
+ "batch_no": rm_item.get("batch_no"),
+ "main_item_code": fg_item_code,
+ "allow_alternative_item": item_wh.get(rm_item_code, {}).get("allow_alternative_item"),
+ }
}
- }
- stock_entry.add_to_stock_entry_detail(items_dict)
- return stock_entry.as_dict()
- else:
- frappe.throw(_("No Items selected for transfer"))
- return subcontract_order.name
+
+ stock_entry.add_to_stock_entry_detail(items_dict)
+
+ if target_doc:
+ return stock_entry
+ else:
+ return stock_entry.as_dict()
+ else:
+ frappe.throw(_("No Items selected for transfer."))
def add_items_in_ste(
@@ -851,7 +919,18 @@ def add_items_in_ste(
def make_return_stock_entry_for_subcontract(
available_materials, order_doc, rm_details, order_doctype="Subcontracting Order"
):
- ste_doc = frappe.new_doc("Stock Entry")
+ ste_doc = get_mapped_doc(
+ order_doctype,
+ order_doc.name,
+ {
+ order_doctype: {
+ "doctype": "Stock Entry",
+ "field_no_map": ["purchase_order", "subcontracting_order"],
+ },
+ },
+ ignore_child_tables=True,
+ )
+
ste_doc.purpose = "Material Transfer"
if order_doctype == "Purchase Order":
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 0d8cffe03f..8c403aa9bf 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -6,6 +6,7 @@ import json
import frappe
from frappe import _, scrub
+from frappe.model.document import Document
from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
import erpnext
@@ -20,7 +21,7 @@ from erpnext.stock.get_item_details import _get_item_tax_template
class calculate_taxes_and_totals(object):
- def __init__(self, doc):
+ def __init__(self, doc: Document):
self.doc = doc
frappe.flags.round_off_applicable_accounts = []
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
@@ -37,6 +38,12 @@ class calculate_taxes_and_totals(object):
self.set_discount_amount()
self.apply_discount_amount()
+ # Update grand total as per cash and non trade discount
+ if self.doc.apply_discount_on == "Grand Total" and self.doc.get("is_cash_or_non_trade_discount"):
+ self.doc.grand_total -= self.doc.discount_amount
+ self.doc.base_grand_total -= self.doc.base_discount_amount
+ self.set_rounded_total()
+
self.calculate_shipping_charges()
if self.doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
@@ -52,12 +59,25 @@ class calculate_taxes_and_totals(object):
self.initialize_taxes()
self.determine_exclusive_rate()
self.calculate_net_total()
+ self.calculate_tax_withholding_net_total()
self.calculate_taxes()
self.manipulate_grand_total_for_inclusive_tax()
self.calculate_totals()
self._cleanup()
self.calculate_total_net_weight()
+ def calculate_tax_withholding_net_total(self):
+ if hasattr(self.doc, "tax_withholding_net_total"):
+ sum_net_amount = 0
+ sum_base_net_amount = 0
+ for item in self.doc.get("items"):
+ if hasattr(item, "apply_tds") and item.apply_tds:
+ sum_net_amount += item.net_amount
+ sum_base_net_amount += item.base_net_amount
+
+ self.doc.tax_withholding_net_total = sum_net_amount
+ self.doc.base_tax_withholding_net_total = sum_base_net_amount
+
def validate_item_tax_template(self):
for item in self.doc.get("items"):
if item.item_code and item.get("item_tax_template"):
@@ -500,9 +520,6 @@ class calculate_taxes_and_totals(object):
else:
self.doc.grand_total = flt(self.doc.net_total)
- if self.doc.apply_discount_on == "Grand Total" and self.doc.get("is_cash_or_non_trade_discount"):
- self.doc.grand_total -= self.doc.discount_amount
-
if self.doc.get("taxes"):
self.doc.total_taxes_and_charges = flt(
self.doc.grand_total - self.doc.net_total - flt(self.doc.rounding_adjustment),
@@ -597,16 +614,16 @@ class calculate_taxes_and_totals(object):
if not self.doc.apply_discount_on:
frappe.throw(_("Please select Apply Discount On"))
+ self.doc.base_discount_amount = flt(
+ self.doc.discount_amount * self.doc.conversion_rate, self.doc.precision("base_discount_amount")
+ )
+
if self.doc.apply_discount_on == "Grand Total" and self.doc.get(
"is_cash_or_non_trade_discount"
):
self.discount_amount_applied = True
return
- self.doc.base_discount_amount = flt(
- self.doc.discount_amount * self.doc.conversion_rate, self.doc.precision("base_discount_amount")
- )
-
total_for_discount_amount = self.get_total_for_discount_amount()
taxes = self.doc.get("taxes")
net_total = 0
@@ -661,7 +678,7 @@ class calculate_taxes_and_totals(object):
)
def calculate_total_advance(self):
- if self.doc.docstatus < 2:
+ if not self.doc.docstatus.is_cancelled():
total_allocated_amount = sum(
flt(adv.allocated_amount, adv.precision("allocated_amount"))
for adv in self.doc.get("advances")
@@ -692,7 +709,7 @@ class calculate_taxes_and_totals(object):
)
)
- if self.doc.docstatus == 0:
+ if self.doc.docstatus.is_draft():
if self.doc.get("write_off_outstanding_amount_automatically"):
self.doc.write_off_amount = 0
@@ -767,6 +784,18 @@ class calculate_taxes_and_totals(object):
self.doc.precision("outstanding_amount"),
)
+ if (
+ self.doc.doctype == "Sales Invoice"
+ and self.doc.get("is_pos")
+ and self.doc.get("pos_profile")
+ and self.doc.get("is_consolidated")
+ ):
+ write_off_limit = flt(
+ frappe.db.get_value("POS Profile", self.doc.pos_profile, "write_off_limit")
+ )
+ if write_off_limit and abs(self.doc.outstanding_amount) <= write_off_limit:
+ self.doc.write_off_outstanding_amount_automatically = 1
+
if (
self.doc.doctype == "Sales Invoice"
and self.doc.get("is_pos")
@@ -874,24 +903,33 @@ class calculate_taxes_and_totals(object):
self.doc.other_charges_calculation = get_itemised_tax_breakup_html(self.doc)
def set_total_amount_to_default_mop(self, total_amount_to_pay):
- default_mode_of_payment = frappe.db.get_value(
- "POS Payment Method",
- {"parent": self.doc.pos_profile, "default": 1},
- ["mode_of_payment"],
- as_dict=1,
- )
-
- if default_mode_of_payment:
- self.doc.payments = []
- self.doc.append(
- "payments",
- {
- "mode_of_payment": default_mode_of_payment.mode_of_payment,
- "amount": total_amount_to_pay,
- "default": 1,
- },
+ total_paid_amount = 0
+ for payment in self.doc.get("payments"):
+ total_paid_amount += (
+ payment.amount if self.doc.party_account_currency == self.doc.currency else payment.base_amount
)
+ pending_amount = total_amount_to_pay - total_paid_amount
+
+ if pending_amount > 0:
+ default_mode_of_payment = frappe.db.get_value(
+ "POS Payment Method",
+ {"parent": self.doc.pos_profile, "default": 1},
+ ["mode_of_payment"],
+ as_dict=1,
+ )
+
+ if default_mode_of_payment:
+ self.doc.payments = []
+ self.doc.append(
+ "payments",
+ {
+ "mode_of_payment": default_mode_of_payment.mode_of_payment,
+ "amount": pending_amount,
+ "default": 1,
+ },
+ )
+
def get_itemised_tax_breakup_html(doc):
if not doc.taxes:
@@ -1019,7 +1057,7 @@ class init_landed_taxes_and_totals(object):
company_currency = erpnext.get_company_currency(self.doc.company)
for d in self.doc.get(self.tax_field):
if not d.account_currency:
- account_currency = frappe.db.get_value("Account", d.expense_account, "account_currency")
+ account_currency = frappe.get_cached_value("Account", d.expense_account, "account_currency")
d.account_currency = account_currency or company_currency
def set_exchange_rate(self):
diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py
index 4fab8058b8..0e6fe95d45 100644
--- a/erpnext/controllers/tests/test_subcontracting_controller.py
+++ b/erpnext/controllers/tests/test_subcontracting_controller.py
@@ -36,6 +36,36 @@ class TestSubcontractingController(FrappeTestCase):
sco.remove_empty_rows()
self.assertEqual((len_before - 1), len(sco.service_items))
+ def test_set_missing_values_in_additional_costs(self):
+ sco = get_subcontracting_order(do_not_submit=1)
+
+ rate_without_additional_cost = sco.items[0].rate
+ amount_without_additional_cost = sco.items[0].amount
+
+ additional_amount = 120
+ sco.append(
+ "additional_costs",
+ {
+ "expense_account": "Cost of Goods Sold - _TC",
+ "description": "Test",
+ "amount": additional_amount,
+ },
+ )
+ sco.save()
+
+ additional_cost_per_qty = additional_amount / sco.items[0].qty
+
+ self.assertEqual(sco.items[0].additional_cost_per_qty, additional_cost_per_qty)
+ self.assertEqual(rate_without_additional_cost + additional_cost_per_qty, sco.items[0].rate)
+ self.assertEqual(amount_without_additional_cost + additional_amount, sco.items[0].amount)
+
+ sco.additional_costs = []
+ sco.save()
+
+ self.assertEqual(sco.items[0].additional_cost_per_qty, 0)
+ self.assertEqual(rate_without_additional_cost, sco.items[0].rate)
+ self.assertEqual(amount_without_additional_cost, sco.items[0].amount)
+
def test_create_raw_materials_supplied(self):
sco = get_subcontracting_order()
sco.supplied_items = None
@@ -785,6 +815,7 @@ def add_second_row_in_scr(scr):
"item_name",
"qty",
"uom",
+ "bom",
"warehouse",
"stock_uom",
"subcontracting_order",
@@ -867,7 +898,7 @@ def make_stock_transfer_entry(**args):
"item_name": row.item_code,
"rate": row.rate or 100,
"stock_uom": row.stock_uom or "Nos",
- "warehouse": row.warehuose or "_Test Warehouse - _TC",
+ "warehouse": row.warehouse or "_Test Warehouse - _TC",
}
item_details = args.itemwise_details.get(row.item_code)
@@ -1001,9 +1032,9 @@ def get_subcontracting_order(**args):
if not args.service_items:
service_items = [
{
- "warehouse": "_Test Warehouse - _TC",
+ "warehouse": args.warehouse or "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 7",
- "qty": 5,
+ "qty": 10,
"rate": 100,
"fg_item": "Subcontracted Item SA7",
"fg_item_qty": 10,
@@ -1016,6 +1047,7 @@ def get_subcontracting_order(**args):
rm_items=service_items,
is_subcontracted=1,
supplier_warehouse=args.supplier_warehouse or "_Test Warehouse 1 - _TC",
+ company=args.company,
)
return create_subcontracting_order(po_name=po.name, **args)
diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py
index 1d6c5dc0be..1fb722e112 100644
--- a/erpnext/controllers/trends.py
+++ b/erpnext/controllers/trends.py
@@ -80,7 +80,7 @@ def get_data(filters, conditions):
if conditions.get("trans") == "Quotation" and filters.get("group_by") == "Customer":
cond += " and t1.quotation_to = 'Customer'"
- year_start_date, year_end_date = frappe.db.get_value(
+ year_start_date, year_end_date = frappe.get_cached_value(
"Fiscal Year", filters.get("fiscal_year"), ["year_start_date", "year_end_date"]
)
@@ -275,7 +275,7 @@ def get_period_date_ranges(period, fiscal_year=None, year_start_date=None):
from dateutil.relativedelta import relativedelta
if not year_start_date:
- year_start_date, year_end_date = frappe.db.get_value(
+ year_start_date, year_end_date = frappe.get_cached_value(
"Fiscal Year", fiscal_year, ["year_start_date", "year_end_date"]
)
diff --git a/erpnext/crm/doctype/appointment/appointment.json b/erpnext/crm/doctype/appointment/appointment.json
index fe7b4e17f0..c26b064c4c 100644
--- a/erpnext/crm/doctype/appointment/appointment.json
+++ b/erpnext/crm/doctype/appointment/appointment.json
@@ -102,7 +102,7 @@
}
],
"links": [],
- "modified": "2021-06-30 13:09:14.228756",
+ "modified": "2022-12-15 11:11:02.131986",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment",
@@ -121,16 +121,6 @@
"share": 1,
"write": 1
},
- {
- "create": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Guest",
- "share": 1
- },
{
"create": 1,
"delete": 1,
@@ -170,5 +160,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/appointment/appointment.py b/erpnext/crm/doctype/appointment/appointment.py
index 5f5923dc89..bd49bdc925 100644
--- a/erpnext/crm/doctype/appointment/appointment.py
+++ b/erpnext/crm/doctype/appointment/appointment.py
@@ -6,8 +6,10 @@ from collections import Counter
import frappe
from frappe import _
+from frappe.desk.form.assign_to import add as add_assignment
from frappe.model.document import Document
-from frappe.utils import get_url, getdate
+from frappe.share import add_docshare
+from frappe.utils import get_url, getdate, now
from frappe.utils.verified_command import get_signed_params
@@ -104,35 +106,44 @@ class Appointment(Document):
# Return if already linked
if self.party:
return
+
lead = frappe.get_doc(
{
"doctype": "Lead",
"lead_name": self.customer_name,
"email_id": self.customer_email,
- "notes": self.customer_details,
"phone": self.customer_phone_number,
}
)
+
+ if self.customer_details:
+ lead.append(
+ "notes",
+ {
+ "note": self.customer_details,
+ "added_by": frappe.session.user,
+ "added_on": now(),
+ },
+ )
+
lead.insert(ignore_permissions=True)
+
# Link lead
self.party = lead.name
def auto_assign(self):
- from frappe.desk.form.assign_to import add as add_assignemnt
-
existing_assignee = self.get_assignee_from_latest_opportunity()
if existing_assignee:
# If the latest opportunity is assigned to someone
# Assign the appointment to the same
- add_assignemnt({"doctype": self.doctype, "name": self.name, "assign_to": [existing_assignee]})
+ self.assign_agent(existing_assignee)
return
if self._assign:
return
available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time))
for agent in available_agents:
if _check_agent_availability(agent, self.scheduled_time):
- agent = agent[0]
- add_assignemnt({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
+ self.assign_agent(agent[0])
break
def get_assignee_from_latest_opportunity(self):
@@ -187,9 +198,15 @@ class Appointment(Document):
params = {"email": self.customer_email, "appointment": self.name}
return get_url(verify_route + "?" + get_signed_params(params))
+ def assign_agent(self, agent):
+ if not frappe.has_permission(doc=self, user=agent):
+ add_docshare(self.doctype, self.name, agent, flags={"ignore_share_permission": True})
+
+ add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
+
def _get_agents_sorted_by_asc_workload(date):
- appointments = frappe.db.get_list("Appointment", fields="*")
+ appointments = frappe.get_all("Appointment", fields="*")
agent_list = _get_agent_list_as_strings()
if not appointments:
return agent_list
@@ -214,7 +231,7 @@ def _get_agent_list_as_strings():
def _check_agent_availability(agent_email, scheduled_time):
- appointemnts_at_scheduled_time = frappe.get_list(
+ appointemnts_at_scheduled_time = frappe.get_all(
"Appointment", filters={"scheduled_time": scheduled_time}
)
for appointment in appointemnts_at_scheduled_time:
diff --git a/erpnext/crm/doctype/appointment/test_appointment.py b/erpnext/crm/doctype/appointment/test_appointment.py
index 776e604333..178b9d2de5 100644
--- a/erpnext/crm/doctype/appointment/test_appointment.py
+++ b/erpnext/crm/doctype/appointment/test_appointment.py
@@ -6,29 +6,20 @@ import unittest
import frappe
-
-def create_test_lead():
- test_lead = frappe.db.get_value("Lead", {"email_id": "test@example.com"})
- if test_lead:
- return frappe.get_doc("Lead", test_lead)
- test_lead = frappe.get_doc(
- {"doctype": "Lead", "lead_name": "Test Lead", "email_id": "test@example.com"}
- )
- test_lead.insert(ignore_permissions=True)
- return test_lead
+LEAD_EMAIL = "test_appointment_lead@example.com"
-def create_test_appointments():
+def create_test_appointment():
test_appointment = frappe.get_doc(
{
"doctype": "Appointment",
- "email": "test@example.com",
"status": "Open",
"customer_name": "Test Lead",
"customer_phone_number": "666",
"customer_skype": "test",
- "customer_email": "test@example.com",
+ "customer_email": LEAD_EMAIL,
"scheduled_time": datetime.datetime.now(),
+ "customer_details": "Hello, Friend!",
}
)
test_appointment.insert()
@@ -36,16 +27,16 @@ def create_test_appointments():
class TestAppointment(unittest.TestCase):
- test_appointment = test_lead = None
+ def setUpClass():
+ frappe.db.delete("Lead", {"email_id": LEAD_EMAIL})
def setUp(self):
- self.test_lead = create_test_lead()
- self.test_appointment = create_test_appointments()
+ self.test_appointment = create_test_appointment()
+ self.test_appointment.set_verified(self.test_appointment.customer_email)
def test_calendar_event_created(self):
cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event)
self.assertEqual(cal_event.starts_on, self.test_appointment.scheduled_time)
def test_lead_linked(self):
- lead = frappe.get_doc("Lead", self.test_lead.name)
- self.assertIsNotNone(lead)
+ self.assertTrue(self.test_appointment.party)
diff --git a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
index 4b26e4901b..436eb10c88 100644
--- a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
+++ b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"creation": "2019-08-27 10:56:48.309824",
"doctype": "DocType",
"editable_grid": 1,
@@ -101,7 +102,8 @@
}
],
"issingle": 1,
- "modified": "2019-11-26 12:14:17.669366",
+ "links": [],
+ "modified": "2022-12-15 11:10:13.517742",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment Booking Settings",
@@ -117,13 +119,6 @@
"share": 1,
"write": 1
},
- {
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "Guest",
- "share": 1
- },
{
"create": 1,
"email": 1,
@@ -147,5 +142,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json
index c946ae4999..077e7fa4af 100644
--- a/erpnext/crm/doctype/lead/lead.json
+++ b/erpnext/crm/doctype/lead/lead.json
@@ -312,7 +312,8 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
- "print_hide": 1
+ "print_hide": 1,
+ "read_only": 1
},
{
"fieldname": "language",
@@ -340,8 +341,8 @@
"fieldname": "no_of_employees",
"fieldtype": "Select",
"label": "No of Employees",
- "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+"
- },
+ "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
+ },
{
"fieldname": "column_break_22",
"fieldtype": "Column Break"
@@ -375,7 +376,7 @@
"depends_on": "eval:!doc.__islocal",
"fieldname": "notes_tab",
"fieldtype": "Tab Break",
- "label": "Notes"
+ "label": "Comments"
},
{
"collapsible": 1,
@@ -506,7 +507,7 @@
{
"fieldname": "dashboard_tab",
"fieldtype": "Tab Break",
- "label": "Dashboard",
+ "label": "Connections",
"show_dashboard": 1
}
],
@@ -514,11 +515,10 @@
"idx": 5,
"image_field": "image",
"links": [],
- "modified": "2022-07-22 15:55:03.176094",
+ "modified": "2023-01-24 18:20:05.044791",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead",
- "name_case": "Title Case",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index 0d12499771..2a588d8d13 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -14,9 +14,6 @@ from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_
class Lead(SellingController, CRMNote):
- def get_feed(self):
- return "{0}: {1}".format(_(self.status), self.lead_name)
-
def onload(self):
customer = frappe.db.get_value("Customer", {"lead_name": self.name})
self.get("__onload").is_customer = customer
@@ -285,6 +282,7 @@ def _make_customer(source_name, target_doc=None, ignore_permissions=False):
"contact_no": "phone_1",
"fax": "fax_1",
},
+ "field_no_map": ["disabled"],
}
},
target_doc,
@@ -393,7 +391,7 @@ def get_lead_details(lead, posting_date=None, company=None):
{
"territory": lead.territory,
"customer_name": lead.company_name or lead.lead_name,
- "contact_display": " ".join(filter(None, [lead.salutation, lead.lead_name])),
+ "contact_display": " ".join(filter(None, [lead.lead_name])),
"contact_email": lead.email_id,
"contact_mobile": lead.mobile_no,
"contact_phone": lead.phone,
@@ -453,6 +451,7 @@ def get_lead_with_phone_number(number):
"Lead",
or_filters={
"phone": ["like", "%{}".format(number)],
+ "whatsapp_no": ["like", "%{}".format(number)],
"mobile_no": ["like", "%{}".format(number)],
},
limit=1,
diff --git a/erpnext/crm/doctype/lead_source/lead_source.json b/erpnext/crm/doctype/lead_source/lead_source.json
index 723c6d993d..c3cedcc7a6 100644
--- a/erpnext/crm/doctype/lead_source/lead_source.json
+++ b/erpnext/crm/doctype/lead_source/lead_source.json
@@ -26,10 +26,11 @@
}
],
"links": [],
- "modified": "2021-02-08 12:51:48.971517",
+ "modified": "2023-02-10 00:51:44.973957",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead Source",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -58,5 +59,7 @@
],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": [],
+ "translated_doctype": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json
index 68a2156981..07641d20c3 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.json
+++ b/erpnext/crm/doctype/opportunity/opportunity.json
@@ -463,7 +463,7 @@
"fieldname": "no_of_employees",
"fieldtype": "Select",
"label": "No of Employees",
- "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+"
+ "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
},
{
"fieldname": "annual_revenue",
@@ -544,14 +544,14 @@
"depends_on": "eval:!doc.__islocal",
"fieldname": "dashboard_tab",
"fieldtype": "Tab Break",
- "label": "Dashboard",
+ "label": "Connections",
"show_dashboard": 1
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "notes_tab",
"fieldtype": "Tab Break",
- "label": "Notes"
+ "label": "Comments"
},
{
"fieldname": "notes_html",
@@ -622,7 +622,7 @@
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
- "modified": "2022-07-22 18:46:32.858696",
+ "modified": "2022-10-13 12:42:21.545636",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 08eb472bb9..f4b6e910ed 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -60,7 +60,7 @@ class Opportunity(TransactionBase, CRMNote):
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
try:
value = frappe.db.get_value(self.opportunity_from, self.party_name, field)
- frappe.db.set(self, field, value)
+ self.db_set(field, value)
except Exception:
continue
diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json
index 7f33c08c13..d32311bc4e 100644
--- a/erpnext/crm/doctype/prospect/prospect.json
+++ b/erpnext/crm/doctype/prospect/prospect.json
@@ -82,7 +82,7 @@
"fieldname": "no_of_employees",
"fieldtype": "Select",
"label": "No. of Employees",
- "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+"
+ "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
},
{
"fieldname": "annual_revenue",
@@ -128,7 +128,7 @@
"depends_on": "eval:!doc.__islocal",
"fieldname": "notes_section",
"fieldtype": "Tab Break",
- "label": "Notes"
+ "label": "Comments"
},
{
"depends_on": "eval: !doc.__islocal",
@@ -218,7 +218,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-06-22 15:10:26.887502",
+ "modified": "2022-10-13 12:29:33.674561",
"modified_by": "Administrator",
"module": "CRM",
"name": "Prospect",
diff --git a/erpnext/crm/doctype/sales_stage/sales_stage.json b/erpnext/crm/doctype/sales_stage/sales_stage.json
index 77aa559b77..caf8ff5b36 100644
--- a/erpnext/crm/doctype/sales_stage/sales_stage.json
+++ b/erpnext/crm/doctype/sales_stage/sales_stage.json
@@ -18,10 +18,11 @@
}
],
"links": [],
- "modified": "2020-05-20 12:22:01.866472",
+ "modified": "2023-02-10 01:40:23.713390",
"modified_by": "Administrator",
"module": "CRM",
"name": "Sales Stage",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -40,5 +41,7 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1
+ "states": [],
+ "track_changes": 1,
+ "translated_doctype": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.js b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.js
index 116db2f5a2..7cd1710a7f 100644
--- a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.js
+++ b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.js
@@ -44,7 +44,7 @@ frappe.query_reports["Opportunity Summary by Sales Stage"] = {
},
{
fieldname: "opportunity_source",
- label: __("Oppoturnity Source"),
+ label: __("Opportunity Source"),
fieldtype: "Link",
options: "Lead Source",
},
@@ -62,4 +62,4 @@ frappe.query_reports["Opportunity Summary by Sales Stage"] = {
default: frappe.defaults.get_user_default("Company")
}
]
-};
\ No newline at end of file
+};
diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
index d23a22ac46..dea3f2dd36 100644
--- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
+++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
@@ -217,7 +217,7 @@ class SalesPipelineAnalytics(object):
def check_for_assigned_to(self, period, value, count_or_amount, assigned_to, info):
if self.filters.get("assigned_to"):
- for data in json.loads(info.get("opportunity_owner")):
+ for data in json.loads(info.get("opportunity_owner") or "[]"):
if data == self.filters.get("assigned_to"):
self.set_formatted_data(period, data, count_or_amount, assigned_to)
else:
diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py
index 433d974a44..737452021c 100644
--- a/erpnext/crm/utils.py
+++ b/erpnext/crm/utils.py
@@ -120,7 +120,7 @@ def link_open_tasks(ref_doctype, ref_docname, doc):
todo_doc = frappe.get_doc("ToDo", todo.name)
todo_doc.reference_type = doc.doctype
todo_doc.reference_name = doc.name
- todo_doc.db_update()
+ todo_doc.save()
def link_open_events(ref_doctype, ref_docname, doc):
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js
index 69b9cfaa68..c37fa2f6ea 100644
--- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js
@@ -48,5 +48,11 @@ frappe.ui.form.on("E Commerce Settings", {
frm.set_value('default_customer_group', '');
frm.set_value('quotation_series', '');
}
+ },
+
+ enable_checkout: function(frm) {
+ if (frm.doc.enable_checkout) {
+ erpnext.utils.check_payments_app();
+ }
}
});
diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py
index 828c655e79..bbe04d5514 100644
--- a/erpnext/e_commerce/doctype/website_item/test_website_item.py
+++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py
@@ -174,7 +174,10 @@ class TestWebsiteItem(unittest.TestCase):
# Website Item Portal Tests Begin
def test_website_item_breadcrumbs(self):
- "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group."
+ """
+ Check if breadcrumbs include homepage, product listing navigation page,
+ parent item group(s) and item group
+ """
from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups
item_code = "Test Breadcrumb Item"
@@ -197,7 +200,7 @@ class TestWebsiteItem(unittest.TestCase):
breadcrumbs = get_parent_item_groups(item.item_group)
self.assertEqual(breadcrumbs[0]["name"], "Home")
- self.assertEqual(breadcrumbs[1]["name"], "Shop by Category")
+ self.assertEqual(breadcrumbs[1]["name"], "All Products")
self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group
self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1")
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json
index a416aac3a1..6556eabf4a 100644
--- a/erpnext/e_commerce/doctype/website_item/website_item.json
+++ b/erpnext/e_commerce/doctype/website_item/website_item.json
@@ -188,7 +188,8 @@
"in_list_view": 1,
"label": "Item Group",
"options": "Item Group",
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"default": "1",
@@ -234,7 +235,8 @@
"fieldname": "brand",
"fieldtype": "Link",
"label": "Brand",
- "options": "Brand"
+ "options": "Brand",
+ "search_index": 1
},
{
"collapsible": 1,
@@ -345,7 +347,8 @@
"image_field": "website_image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-06-28 17:10:30.613251",
+ "make_attachments_public": 1,
+ "modified": "2022-09-30 04:01:52.090732",
"modified_by": "Administrator",
"module": "E-commerce",
"name": "Website Item",
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py
index c0f8c79283..3e5d5f768f 100644
--- a/erpnext/e_commerce/doctype/website_item/website_item.py
+++ b/erpnext/e_commerce/doctype/website_item/website_item.py
@@ -403,9 +403,6 @@ def on_doctype_update():
# since route is a Text column, it needs a length for indexing
frappe.db.add_index("Website Item", ["route(500)"])
- frappe.db.add_index("Website Item", ["item_group"])
- frappe.db.add_index("Website Item", ["brand"])
-
def check_if_user_is_customer(user=None):
from frappe.contacts.doctype.contact.contact import get_contact_name
diff --git a/erpnext/e_commerce/product_ui/search.js b/erpnext/e_commerce/product_ui/search.js
index 61922459e5..1688cc1fb6 100644
--- a/erpnext/e_commerce/product_ui/search.js
+++ b/erpnext/e_commerce/product_ui/search.js
@@ -200,7 +200,7 @@ erpnext.ProductSearch = class {
let thumbnail = res.thumbnail || '/assets/erpnext/images/ui-states/cart-empty-state.png';
html += `
- 
+
${res.web_item_name}
${res.brand ? "by " + res.brand : ""}
@@ -241,4 +241,4 @@ erpnext.ProductSearch = class {
this.category_container.html(html);
}
-};
\ No newline at end of file
+};
diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py
index 1f649c7b48..87ca9bd83d 100644
--- a/erpnext/e_commerce/redisearch_utils.py
+++ b/erpnext/e_commerce/redisearch_utils.py
@@ -7,7 +7,9 @@ import frappe
from frappe import _
from frappe.utils.redis_wrapper import RedisWrapper
from redis import ResponseError
-from redisearch import AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField
+from redis.commands.search.field import TagField, TextField
+from redis.commands.search.indexDefinition import IndexDefinition
+from redis.commands.search.suggestion import Suggestion
WEBSITE_ITEM_INDEX = "website_items_index"
WEBSITE_ITEM_KEY_PREFIX = "website_item:"
@@ -35,12 +37,9 @@ def is_redisearch_enabled():
def is_search_module_loaded():
try:
cache = frappe.cache()
- out = cache.execute_command("MODULE LIST")
-
- parsed_output = " ".join(
- (" ".join([frappe.as_unicode(s) for s in o if not isinstance(s, int)]) for o in out)
- )
- return "search" in parsed_output
+ for module in cache.module_list():
+ if module.get(b"name") == b"search":
+ return True
except Exception:
return False # handling older redis versions
@@ -58,18 +57,18 @@ def if_redisearch_enabled(function):
def make_key(key):
- return "{0}|{1}".format(frappe.conf.db_name, key).encode("utf-8")
+ return frappe.cache().make_key(key)
@if_redisearch_enabled
def create_website_items_index():
"Creates Index Definition."
- # CREATE index
- client = Client(make_key(WEBSITE_ITEM_INDEX), conn=frappe.cache())
+ redis = frappe.cache()
+ index = redis.ft(WEBSITE_ITEM_INDEX)
try:
- client.drop_index() # drop if already exists
+ index.dropindex() # drop if already exists
except ResponseError:
# will most likely raise a ResponseError if index does not exist
# ignore and create index
@@ -86,9 +85,10 @@ def create_website_items_index():
if "web_item_name" in idx_fields:
idx_fields.remove("web_item_name")
- idx_fields = list(map(to_search_field, idx_fields))
+ idx_fields = [to_search_field(f) for f in idx_fields]
- client.create_index(
+ # TODO: sortable?
+ index.create_index(
[TextField("web_item_name", sortable=True)] + idx_fields,
definition=idx_def,
)
@@ -119,8 +119,8 @@ def insert_item_to_index(website_item_doc):
@if_redisearch_enabled
def insert_to_name_ac(web_name, doc_name):
- ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache())
- ac.add_suggestions(Suggestion(web_name, payload=doc_name))
+ ac = frappe.cache().ft()
+ ac.sugadd(WEBSITE_ITEM_NAME_AUTOCOMPLETE, Suggestion(web_name, payload=doc_name))
def create_web_item_map(website_item_doc):
@@ -157,9 +157,8 @@ def delete_item_from_index(website_item_doc):
@if_redisearch_enabled
def delete_from_ac_dict(website_item_doc):
"""Removes this items's name from autocomplete dictionary"""
- cache = frappe.cache()
- name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
- name_ac.delete(website_item_doc.web_item_name)
+ ac = frappe.cache().ft()
+ ac.sugdel(website_item_doc.web_item_name)
@if_redisearch_enabled
@@ -170,8 +169,6 @@ def define_autocomplete_dictionary():
"""
cache = frappe.cache()
- item_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
- item_group_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache)
# Delete both autocomplete dicts
try:
@@ -180,38 +177,43 @@ def define_autocomplete_dictionary():
except Exception:
raise_redisearch_error()
- create_items_autocomplete_dict(autocompleter=item_ac)
- create_item_groups_autocomplete_dict(autocompleter=item_group_ac)
+ create_items_autocomplete_dict()
+ create_item_groups_autocomplete_dict()
@if_redisearch_enabled
-def create_items_autocomplete_dict(autocompleter):
+def create_items_autocomplete_dict():
"Add items as suggestions in Autocompleter."
+
+ ac = frappe.cache().ft()
items = frappe.get_all(
"Website Item", fields=["web_item_name", "item_group"], filters={"published": 1}
)
-
for item in items:
- autocompleter.add_suggestions(Suggestion(item.web_item_name))
+ ac.sugadd(WEBSITE_ITEM_NAME_AUTOCOMPLETE, Suggestion(item.web_item_name))
@if_redisearch_enabled
-def create_item_groups_autocomplete_dict(autocompleter):
+def create_item_groups_autocomplete_dict():
"Add item groups with weightage as suggestions in Autocompleter."
+
published_item_groups = frappe.get_all(
"Item Group", fields=["name", "route", "weightage"], filters={"show_in_website": 1}
)
if not published_item_groups:
return
+ ac = frappe.cache().ft()
+
for item_group in published_item_groups:
payload = json.dumps({"name": item_group.name, "route": item_group.route})
- autocompleter.add_suggestions(
+ ac.sugadd(
+ WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE,
Suggestion(
string=item_group.name,
score=frappe.utils.flt(item_group.weightage) or 1.0,
payload=payload, # additional info that can be retrieved later
- )
+ ),
)
diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js
index b649d9d6cc..241129719b 100644
--- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js
+++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js
@@ -2,4 +2,7 @@
// For license information, please see license.txt
frappe.ui.form.on('GoCardless Settings', {
+ refresh: function(frm) {
+ erpnext.utils.check_payments_app();
+ }
});
diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json
index 9738106a30..cca36536ac 100644
--- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json
+++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json
@@ -173,7 +173,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2018-02-12 14:18:47.209114",
+ "modified": "2022-02-12 14:18:47.209114",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "GoCardless Settings",
@@ -201,7 +201,6 @@
"write": 1
}
],
- "quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
index f9a293fc30..4a29a6a21d 100644
--- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
+++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
@@ -10,7 +10,8 @@ from frappe import _
from frappe.integrations.utils import create_request_log
from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, flt, get_url
-from payments.utils import create_payment_gateway
+
+from erpnext.utilities import payment_app_import_guard
class GoCardlessSettings(Document):
@@ -30,6 +31,9 @@ class GoCardlessSettings(Document):
frappe.throw(e)
def on_update(self):
+ with payment_app_import_guard():
+ from payments.utils import create_payment_gateway
+
create_payment_gateway(
"GoCardless-" + self.gateway_name, settings="GoCardLess Settings", controller=self.gateway_name
)
diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js
index 7c8ae5c802..447d720ca2 100644
--- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js
+++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js
@@ -7,6 +7,8 @@ frappe.ui.form.on('Mpesa Settings', {
},
refresh: function(frm) {
+ erpnext.utils.check_payments_app();
+
frappe.realtime.on("refresh_mpesa_dashboard", function(){
frm.reload_doc();
frm.events.setup_account_balance_html(frm);
diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py
index b534783864..a298e11eaf 100644
--- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py
+++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py
@@ -9,13 +9,13 @@ from frappe import _
from frappe.integrations.utils import create_request_log
from frappe.model.document import Document
from frappe.utils import call_hook_method, fmt_money, get_request_site_address
-from payments.utils import create_payment_gateway
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import (
create_custom_pos_fields,
)
from erpnext.erpnext_integrations.utils import create_mode_of_payment
+from erpnext.utilities import payment_app_import_guard
class MpesaSettings(Document):
@@ -30,6 +30,9 @@ class MpesaSettings(Document):
)
def on_update(self):
+ with payment_app_import_guard():
+ from payments.utils import create_payment_gateway
+
create_custom_pos_fields()
create_payment_gateway(
"Mpesa-" + self.payment_gateway_name,
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py
index 38d69932f2..f44fad333c 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py
@@ -12,7 +12,7 @@ class PlaidConnector:
def __init__(self, access_token=None):
self.access_token = access_token
self.settings = frappe.get_single("Plaid Settings")
- self.products = ["auth", "transactions"]
+ self.products = ["transactions"]
self.client_name = frappe.local.site
self.client = plaid.Client(
client_id=self.settings.plaid_client_id,
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js
index 3740d04983..3ba6bb9987 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js
@@ -47,7 +47,7 @@ erpnext.integrations.plaidLink = class plaidLink {
}
async init_config() {
- this.product = ["auth", "transactions"];
+ this.product = ["transactions"];
this.plaid_env = this.frm.doc.plaid_env;
this.client_name = frappe.boot.sitename;
this.token = await this.get_link_token();
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
index 62ea85fc5d..f3aa6a3793 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
@@ -70,7 +70,8 @@ def add_bank_accounts(response, bank, company):
except TypeError:
pass
- bank = json.loads(bank)
+ if isinstance(bank, str):
+ bank = json.loads(bank)
result = []
default_gl_account = get_default_bank_cash_account(company, "Bank")
@@ -177,16 +178,15 @@ def sync_transactions(bank, bank_account):
)
result = []
- for transaction in reversed(transactions):
- result += new_bank_transaction(transaction)
+ if transactions:
+ for transaction in reversed(transactions):
+ result += new_bank_transaction(transaction)
if result:
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")
frappe.logger().info(
- "Plaid added {} new Bank Transactions from '{}' between {} and {}".format(
- len(result), bank_account, start_date, end_date
- )
+ f"Plaid added {len(result)} new Bank Transactions from '{bank_account}' between {start_date} and {end_date}"
)
frappe.db.set_value(
@@ -230,19 +230,20 @@ def new_bank_transaction(transaction):
bank_account = frappe.db.get_value("Bank Account", dict(integration_id=transaction["account_id"]))
- if float(transaction["amount"]) >= 0:
- debit = 0
- credit = float(transaction["amount"])
+ amount = float(transaction["amount"])
+ if amount >= 0.0:
+ deposit = 0.0
+ withdrawal = amount
else:
- debit = abs(float(transaction["amount"]))
- credit = 0
+ deposit = abs(amount)
+ withdrawal = 0.0
status = "Pending" if transaction["pending"] == "True" else "Settled"
tags = []
try:
tags += transaction["category"]
- tags += ["Plaid Cat. {}".format(transaction["category_id"])]
+ tags += [f'Plaid Cat. {transaction["category_id"]}']
except KeyError:
pass
@@ -254,11 +255,18 @@ def new_bank_transaction(transaction):
"date": getdate(transaction["date"]),
"status": status,
"bank_account": bank_account,
- "deposit": debit,
- "withdrawal": credit,
+ "deposit": deposit,
+ "withdrawal": withdrawal,
"currency": transaction["iso_currency_code"],
"transaction_id": transaction["transaction_id"],
- "reference_number": transaction["payment_meta"]["reference_number"],
+ "transaction_type": (
+ transaction["transaction_code"] or transaction["payment_meta"]["payment_method"]
+ ),
+ "reference_number": (
+ transaction["check_number"]
+ or transaction["payment_meta"]["reference_number"]
+ or transaction["name"]
+ ),
"description": transaction["name"],
}
)
@@ -271,7 +279,7 @@ def new_bank_transaction(transaction):
result.append(new_transaction.name)
except Exception:
- frappe.throw(title=_("Bank transaction creation error"))
+ frappe.throw(_("Bank transaction creation error"))
return result
@@ -300,3 +308,26 @@ def enqueue_synchronization():
def get_link_token_for_update(access_token):
plaid = PlaidConnector(access_token)
return plaid.get_link_token(update_mode=True)
+
+
+def get_company(bank_account_name):
+ from frappe.defaults import get_user_default
+
+ company_names = frappe.db.get_all("Company", pluck="name")
+ if len(company_names) == 1:
+ return company_names[0]
+ if frappe.db.exists("Bank Account", bank_account_name):
+ return frappe.db.get_value("Bank Account", bank_account_name, "company")
+ company_default = get_user_default("Company")
+ if company_default:
+ return company_default
+ frappe.throw(_("Could not detect the Company for updating Bank Accounts"))
+
+
+@frappe.whitelist()
+def update_bank_account_ids(response):
+ data = json.loads(response)
+ institution_name = data["institution"]["name"]
+ bank = frappe.get_doc("Bank", institution_name).as_dict()
+ bank_account_name = f"{data['account']['name']} - {institution_name}"
+ return add_bank_accounts(response, bank, get_company(bank_account_name))
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py
index e8dc3e258f..6d34a204cd 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py
@@ -125,6 +125,8 @@ class TestPlaidSettings(unittest.TestCase):
"unofficial_currency_code": None,
"name": "INTRST PYMNT",
"transaction_type": "place",
+ "transaction_code": "direct debit",
+ "check_number": "3456789",
"amount": -4.22,
"location": {
"city": None,
diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py
index b93c5c4d38..da5699776f 100644
--- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py
+++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py
@@ -1345,7 +1345,7 @@ class QuickBooksMigrator(Document):
)[0]["name"]
def _publish(self, *args, **kwargs):
- frappe.publish_realtime("quickbooks_progress_update", *args, **kwargs)
+ frappe.publish_realtime("quickbooks_progress_update", *args, **kwargs, user=self.modified_by)
def _get_unique_account_name(self, quickbooks_name, number=0):
if number:
diff --git a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
index 7d676e4235..e6840f505b 100644
--- a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
+++ b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
@@ -12,7 +12,9 @@ from decimal import Decimal
import frappe
from bs4 import BeautifulSoup as bs
from frappe import _
-from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+from frappe.custom.doctype.custom_field.custom_field import (
+ create_custom_fields as _create_custom_fields,
+)
from frappe.model.document import Document
from frappe.utils.data import format_datetime
@@ -302,6 +304,7 @@ class TallyMigration(Document):
frappe.publish_realtime(
"tally_migration_progress_update",
{"title": title, "message": message, "count": count, "total": total},
+ user=self.modified_by,
)
def _import_master_data(self):
@@ -577,22 +580,25 @@ class TallyMigration(Document):
new_year.save()
oldest_year = new_year
- def create_custom_fields(doctypes):
- tally_guid_df = {
- "fieldtype": "Data",
- "fieldname": "tally_guid",
- "read_only": 1,
- "label": "Tally GUID",
- }
- tally_voucher_no_df = {
- "fieldtype": "Data",
- "fieldname": "tally_voucher_no",
- "read_only": 1,
- "label": "Tally Voucher Number",
- }
- for df in [tally_guid_df, tally_voucher_no_df]:
- for doctype in doctypes:
- create_custom_field(doctype, df)
+ def create_custom_fields():
+ _create_custom_fields(
+ {
+ ("Journal Entry", "Purchase Invoice", "Sales Invoice"): [
+ {
+ "fieldtype": "Data",
+ "fieldname": "tally_guid",
+ "read_only": 1,
+ "label": "Tally GUID",
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "tally_voucher_no",
+ "read_only": 1,
+ "label": "Tally Voucher Number",
+ },
+ ]
+ }
+ )
def create_price_list():
frappe.get_doc(
@@ -628,7 +634,7 @@ class TallyMigration(Document):
create_fiscal_years(vouchers)
create_price_list()
- create_custom_fields(["Journal Entry", "Purchase Invoice", "Sales Invoice"])
+ create_custom_fields()
total = len(vouchers)
is_last = False
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json b/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json
deleted file mode 100644
index d4d4a512b5..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "actions": [],
- "allow_rename": 1,
- "creation": "2021-09-11 05:09:53.773838",
- "doctype": "DocType",
- "engine": "InnoDB",
- "field_order": [
- "region",
- "region_code",
- "country",
- "country_code"
- ],
- "fields": [
- {
- "fieldname": "region",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Region"
- },
- {
- "fieldname": "region_code",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Region Code"
- },
- {
- "fieldname": "country",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Country"
- },
- {
- "fieldname": "country_code",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Country Code"
- }
- ],
- "index_web_pages_for_search": 1,
- "istable": 1,
- "links": [],
- "modified": "2021-09-14 05:33:06.444710",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "TaxJar Nexus",
- "owner": "Administrator",
- "permissions": [],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/product_tax_category_data.json b/erpnext/erpnext_integrations/doctype/taxjar_settings/product_tax_category_data.json
deleted file mode 100644
index 4527bb2538..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/product_tax_category_data.json
+++ /dev/null
@@ -1,4084 +0,0 @@
-{
- "categories": [
- {
- "description": "An item commonly used by a student in a course of study. This category is limited to the following items...binders, blackboard chalk, cellophane tape, compasses, composition books, crayons, erasers, folders, glue/paste/glue sticks, highlighters, index cards, index card boxes, legal pads, lunch boxes, markers, notebooks, paper (copy, graph, tracing, manila, colored, construction, notebook), pencils, pencil boxes, pencil sharpeners, pens, posterboard, protractors, rulers, scissors, writing tablets.",
- "name": "School Supplies",
- "product_tax_code": "44121600A0001"
- },
- {
- "description": "This is a labor charge for: the planning and design of interior spaces; preparation of layout drawings, schedules, and specifications pertaining to the planning and design of interior spaces; furniture arranging; design and planning of furniture, fixtures, and cabinetry; staging; lighting and sound design; and the selection, purchase, and arrangement of surface coverings, draperies, furniture, and other decorations.",
- "name": "Interior Decorating Services",
- "product_tax_code": "73890600A0000"
- },
- {
- "description": "Ammunition for firearms with a barrel greater than an internal diameter of .50 caliber or a shotgun larger than 10 gauge., including bullets, shotgun shells, and gunpowder.",
- "name": "Ammunition - designed for firearms other than small arms.",
- "product_tax_code": "46101600A0002"
- },
- {
- "description": "Firearms, limited to pistols, revolvers, rifles with a barrel greater than an internal diameter of .50 caliber or a shotgun larger than 10 gauge.",
- "name": "Firearms - other than small arms",
- "product_tax_code": "46101500A0002"
- },
- {
- "description": "A charge for an objective visual examination of a house’s systems and physical structure. The charge includes a report of the inspector's findings including pictures, analysis, and recommendations.",
- "name": "Home Inspection Services",
- "product_tax_code": "80131802A0001"
- },
- {
- "description": "A charge for custodial services to residential structures, including the cleaning of floors, carpets, walls, windows, appliances, furniture, fixtures, exterior cleaning, etc. No Tangible Personal Property is transferred.",
- "name": "Cleaning/Janitorial Services - Residential",
- "product_tax_code": "76111501A0001"
- },
- {
- "description": "A subscription service for membership to an online dating platform.",
- "name": "Online Dating Services",
- "product_tax_code": "91000000A1111"
- },
- {
- "description": "A charge for the service to maintain the proper operation of home or building gutters through cleaning out debris that could otherwise affect the proper water flow through the gutter system.",
- "name": "Gutter Cleaning Services",
- "product_tax_code": "72152602A0001"
- },
- {
- "description": "Clothing - Swim Fins",
- "name": "Clothing - Swim Fins",
- "product_tax_code": "4914606A0001"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods can be streamed and/or downloaded to a device with permanent access granted. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - bundle - downloaded with permanent rights and streamed - non subscription",
- "product_tax_code": "55111516A0110"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods can be streamed and/or downloaded to a device with access that expires after a stated period of time. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - bundle - downloaded with limited rights and streamed - non subscription",
- "product_tax_code": "55111516A0210"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods are downloaded to a device with access that expires after a stated period of time. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - downloaded - non subscription - with limited rights",
- "product_tax_code": "55111516A0020"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods are downloaded to a device with permanent access granted. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - downloaded - non subscription - with permanent rights",
- "product_tax_code": "55111516A0010"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods are streamed to a device with access that expires after a stated period of time. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - streamed - non subscription - with limited rights",
- "product_tax_code": "55111516A0030"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods are streamed to a device with access that is conditioned upon continued subscription payment. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - streamed - subscription - with conditional rights",
- "product_tax_code": "55111516A0040"
- },
- {
- "description": "A series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any. These goods are streamed and/or downloaded to a device with access that is conditioned upon continued subscription payment. These goods include motion pictures, music videos, animations, news and entertainment programs, and live events, but do not include video greeting cards or video or electronic games.",
- "name": "Digital Audio Visual Works - bundle - downloaded and streamed - subscription - with conditional rights",
- "product_tax_code": "55111516A0310"
- },
- {
- "description": "Works that result from the fixation of a series of musical, spoken, or other sounds that are transferred electronically. These goods are streamed to a device with access that is conditioned upon continued subscription payment. These goods include prerecorded or live music, prerecorded or live readings of books or other written materials, prerecorded or live speeches, ringtones, or other sound recordings, but not including audio greeting cards.",
- "name": "Digital Audio Works - streamed - subscription - with conditional rights",
- "product_tax_code": "55111512A0040"
- },
- {
- "description": "Works that result from the fixation of a series of musical, spoken, or other sounds that are transferred electronically. These goods are streamed to a device with access that expires after a stated period of time. These goods include prerecorded or live music, prerecorded or live readings of books or other written materials, prerecorded or live speeches, ringtones, or other sound recordings, but not including audio greeting cards.",
- "name": "Digital Audio Works - streamed - non subscription - with limited rights",
- "product_tax_code": "55111512A0030"
- },
- {
- "description": "Works that result from the fixation of a series of musical, spoken, or other sounds that are transferred electronically. These goods are downloaded to a device with permanent access granted. These goods include prerecorded or live music, prerecorded or live readings of books or other written materials, prerecorded or live speeches, ringtones, or other sound recordings, but not including audio greeting cards.",
- "name": "Digital Audio Works - downloaded - non subscription - with permanent rights",
- "product_tax_code": "55111512A0010"
- },
- {
- "description": "Works that result from the fixation of a series of musical, spoken, or other sounds that are transferred electronically. These goods are downloaded to a device with access that expires after a stated period of time. These goods include prerecorded or live music, prerecorded or live readings of books or other written materials, prerecorded or live speeches, ringtones, or other sound recordings, but not including audio greeting cards.",
- "name": "Digital Audio Works - downloaded - non subscription - with limited rights",
- "product_tax_code": "55111512A0020"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital Newspapers - viewable only - subscription - with conditional rights",
- "product_tax_code": "55111507A0060"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with permanent access granted. The publication is accessed without a subscription.",
- "name": "Digital Newspapers - viewable only - non subscription - with permanent rights",
- "product_tax_code": "55111507A0040"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with access that expires after a stated period of time. The publication is accessed without a subscription.",
- "name": "Digital Newspapers - viewable only - non subscription - with limited rights",
- "product_tax_code": "55111507A0030"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals. The publication is accessed via a subscription which also entitles the purchaser to physical copies of the media.",
- "name": "Digital Newspapers - subscription tangible and digital",
- "product_tax_code": "55111507A0070"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles downloaded to a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital Newspapers - downloadable - subscription - with conditional rights",
- "product_tax_code": "55111507A0050"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles downloaded to a device with permanent access granted. The publication is accessed without a subscription.",
- "name": "Digital Newspapers - downloadable - non subscription - with permanent rights",
- "product_tax_code": "55111507A0010"
- },
- {
- "description": "A digital version of a traditional newspaper published at regular intervals with the entire publication or individual articles downloaded to a device with access that expires after a stated period of time. The publication is accessed without a subscription.",
- "name": "Digital Newspapers - downloadable - non subscription - with limited rights",
- "product_tax_code": "55111507A0020"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital Magazines/Periodicals - viewable only - subscription - with conditional rights",
- "product_tax_code": "55111506A0060"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with permanent access granted. The publication is accessed without a subscription.",
- "name": "Digital Magazines/Periodicals - viewable only - non subscription - with permanent rights",
- "product_tax_code": "55111506A0040"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles viewable (but not downloadable) on a device with access that expires after a stated period of time. The publication is accessed without a subscription.",
- "name": "Digital Magazines/Periodicals - viewable only - non subscription - with limited rights",
- "product_tax_code": "55111506A0030"
- },
- {
- "description": "A digital version of a traditional magazine published at regular intervals. The publication is accessed via a subscription which also entitles the purchaser to physical copies of the media.",
- "name": "Digital Magazines/Periodicals - subscription tangible and digital",
- "product_tax_code": "55111506A0070"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles downloaded to a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital Magazines/Periodicals - downloadable - subscription - with conditional rights",
- "product_tax_code": "55111506A0050"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles downloaded to a device with permanent access granted. The publication is accessed without a subscription.",
- "name": "Digital Magazines/Periodicals - downloadable - non subscription - with permanent rights",
- "product_tax_code": "55111506A0010"
- },
- {
- "description": "A digital version of a traditional periodical published at regular intervals with the entire publication or individual articles downloaded to a device with access that expires after a stated period of time. The publication is accessed without a subscription.",
- "name": "Digital Magazines/Periodicals - downloadable - non subscription - with limited rights",
- "product_tax_code": "55111506A0020"
- },
- {
- "description": "Works that are generally recognized in the ordinary and usual sense as books and are transferred electronically. These goods are viewable (but not downloadable) on a device with access that is conditioned upon continued subscription payment. These goods include novels, autobiographies, encyclopedias, dictionaries, repair manuals, phone directories, business directories, zip code directories, cookbooks, etc.",
- "name": "Digital Books - viewable only - subscription - with conditional rights",
- "product_tax_code": "55111505A0060"
- },
- {
- "description": "Works that are generally recognized in the ordinary and usual sense as books and are transferred electronically. These goods are downloaded to a device with access that is conditioned upon continued subscription payment. These goods include novels, autobiographies, encyclopedias, dictionaries, repair manuals, phone directories, business directories, zip code directories, cookbooks, etc.",
- "name": "Digital Books - downloaded - subscription - with conditional rights",
- "product_tax_code": "55111505A0050"
- },
- {
- "description": "Works that are generally recognized in the ordinary and usual sense as books and are transferred electronically. These goods are downloaded to a device with permanent access granted. These goods include novels, autobiographies, encyclopedias, dictionaries, repair manuals, phone directories, business directories, zip code directories, cookbooks, etc.",
- "name": "Digital Books - downloaded - non subscription - with permanent rights",
- "product_tax_code": "55111505A0010"
- },
- {
- "description": "Works that are generally recognized in the ordinary and usual sense as books and are transferred electronically. These goods are downloaded to a device with access that expires after a stated period of time. These goods include novels, autobiographies, encyclopedias, dictionaries, repair manuals, phone directories, business directories, zip code directories, cookbooks, etc.",
- "name": "Digital Books - downloaded - non subscription - with limited rights",
- "product_tax_code": "55111505A0020"
- },
- {
- "description": "The final art used for actual reproduction by photomechanical or other processes or for display purposes, but does not include website or home page design, and that is transferred electronically. These goods are downloaded to a device with access that is conditioned upon continued subscription payment. These goods include drawings, paintings, designs, photographs, lettering, paste-ups, mechanicals, assemblies, charts, graphs, illustrative materials, etc.",
- "name": "Digital Finished Artwork - downloaded - subscription - with conditional rights",
- "product_tax_code": "82141502A0050"
- },
- {
- "description": "The final art used for actual reproduction by photomechanical or other processes or for display purposes, but does not include website or home page design, and that is transferred electronically. These goods are downloaded to a device with permanent access granted. These goods include drawings, paintings, designs, photographs, lettering, paste-ups, mechanicals, assemblies, charts, graphs, illustrative materials, etc.",
- "name": "Digital Finished Artwork - downloaded - non subscription - with permanent rights",
- "product_tax_code": "82141502A0010"
- },
- {
- "description": "The final art used for actual reproduction by photomechanical or other processes or for display purposes, but does not include website or home page design, and that is transferred electronically. These goods are downloaded to a device with access that expires after a stated period of time. These goods include drawings, paintings, designs, photographs, lettering, paste-ups, mechanicals, assemblies, charts, graphs, illustrative materials, etc.",
- "name": "Digital Finished Artwork - downloaded - non subscription - with limited rights",
- "product_tax_code": "82141502A0020"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are viewable (but not downloadable) on a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital other news or documents - viewable only - subscription - with conditional rights",
- "product_tax_code": "82111900A0002"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are viewable (but not downloadable) on a device with permanent access granted.",
- "name": "Digital other news or documents - viewable only - non subscription - with permanent rights",
- "product_tax_code": "82111900A0005"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are viewable (but not downloadable) on a device with access that expires after a stated period of time.",
- "name": "Digital other news or documents - viewable only - non subscription - with limited rights",
- "product_tax_code": "82111900A0006"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are downloaded to a device with access that is conditioned upon continued subscription payment.",
- "name": "Digital other news or documents - downloadable - subscription - with conditional rights",
- "product_tax_code": "82111900A0001"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are downloaded to a device with permanent access granted. These publications are accessed without a subscription.",
- "name": "Digital other news or documents - downloadable - non subscription - with permanent rights",
- "product_tax_code": "82111900A0003"
- },
- {
- "description": "Individual digital news articles, newsletters, and other stand-alone documents. These goods are downloaded to a device with access that expires after a stated period of time.",
- "name": "Digital other news or documents - downloadable - non subscription - with limited rights",
- "product_tax_code": "82111900A0004"
- },
- {
- "description": "Works that are required as part of a formal academic education program and are transferred electronically. These goods are downloaded to a device with permanent access granted.",
- "name": "Digital School Textbooks - downloaded - non subscription - with permanent rights",
- "product_tax_code": "55111513A0010"
- },
- {
- "description": "Works that are required as part of a formal academic education program and are transferred electronically. These goods are downloaded to a device with access that expires after a stated period of time.",
- "name": "Digital School Textbooks - downloaded - non subscription - with limited rights",
- "product_tax_code": "55111513A0020"
- },
- {
- "description": "An electronic greeting \"card\" typically sent via email that contains only static images or text, rather than an audio visual or audio only experience.",
- "name": "Digital Greeting Cards - Static text and/or images only",
- "product_tax_code": "14111605A0003"
- },
- {
- "description": "An electronic greeting \"card\" typically sent via email that contains a series of related images which, when shown in succession, impart an impression of motion, together with accompanying sounds, if any.",
- "name": "Digital Greeting Cards - Audio Visual",
- "product_tax_code": "14111605A0001"
- },
- {
- "description": "An electronic greeting \"card\" typically sent via email that contains an audio only message.",
- "name": "Digital Greeting Cards - Audio Only",
- "product_tax_code": "14111605A0002"
- },
- {
- "description": "Digital images that are downloaded to a device with permanent access granted.",
- "name": "Digital Photographs/Images - downloaded - non subscription - with permanent rights for permanent use",
- "product_tax_code": "60121011A0001"
- },
- {
- "description": "Video or electronic games in the common sense are transferred electronically. These goods are streamed to a device with access that is conditioned upon continued subscription payment.",
- "name": "Video Games - streamed - subscription - with conditional rights",
- "product_tax_code": "60141104A0040"
- },
- {
- "description": "Video or electronic games in the common sense are transferred electronically. These goods are streamed to a device with access that expires after a stated period of time.",
- "name": "Video Games - streamed - non subscription - with limited rights",
- "product_tax_code": "60141104A0030"
- },
- {
- "description": "Video or electronic games in the common sense are transferred electronically. These goods are downloaded to a device with access that is conditioned upon continued subscription payment.",
- "name": "Video Games - downloaded - subscription - with conditional rights",
- "product_tax_code": "60141104A0050"
- },
- {
- "description": "Video or electronic games in the common sense are transferred electronically. These goods are downloaded to a device with permanent access granted.",
- "name": "Video Games - downloaded - non subscription - with permanent rights",
- "product_tax_code": "60141104A0010"
- },
- {
- "description": "Video or electronic games in the common sense are transferred electronically. These goods are downloaded to a device with access that expires after a stated period of time.",
- "name": "Video Games - downloaded - non subscription - with limited rights",
- "product_tax_code": "60141104A0020"
- },
- {
- "description": "The conceptualize, design, program or maintain a website. The code is unique to a particular client's website.",
- "name": "Web Site Design",
- "product_tax_code": "81112103A0000"
- },
- {
- "description": "The process of renting or buying space to house a website on the World Wide Web. Website content such as HTML, CSS, and images has to be housed on a server to be viewable online.",
- "name": "Web Hosting Services",
- "product_tax_code": "81112105A0000"
- },
- {
- "description": "A charge separately stated from the sale of the product itself that entitles the purchaser to future repair and labor services to return the defective item of tangible personal property to its original state. The warranty contract is optional to the purchaser. Motor vehicle warranties are excluded.",
- "name": "Warranty - Optional",
- "product_tax_code": "81111818A0000"
- },
- {
- "description": "A charge separately stated from the sale of the product itself that entitles the purchaser to future repair and labor services to return the defective item of tangible personal property to its original state. The warranty contract is mandatory and is required to be purchased on conjunction with the purchased tangible personal property. Motor vehicle warranties are excluded.",
- "name": "Warranty - Mandatory",
- "product_tax_code": "81111818A0001"
- },
- {
- "description": "Personal or small group teaching, designed to help people who need extra help with their studies",
- "name": "Tutoring",
- "product_tax_code": "86132001A0000"
- },
- {
- "description": "Self Study web based training, not instructor led. This does not include downloads of video replays.",
- "name": "Training Services - Self Study Web Based",
- "product_tax_code": "86132000A0002"
- },
- {
- "description": "Live training web based. This does not include video replays of the instruction or course.",
- "name": "Training Services - Live Virtual",
- "product_tax_code": "86132201A0000"
- },
- {
- "description": "Charges for installing, configuring, debugging, modifying, testing, or troubleshooting computer hardware, networks, programs or software. Labor only charge.",
- "name": "Technical Support Services",
- "product_tax_code": "81111811A0001"
- },
- {
- "description": "A charge to preserve an animal's body via mounting or stuffing, for the purpose of display or study. The customer provide the animal. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Taxidermy Services",
- "product_tax_code": "82151508A0000"
- },
- {
- "description": "A charge to have files or documents shredded either onsite or offsite.",
- "name": "Shredding Service",
- "product_tax_code": "44101603A9007"
- },
- {
- "description": "A charge to repair or restore footwear was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential. Note: This product tax code will partially apply tax in CA, MI, IL.",
- "name": "Shoe Repair",
- "product_tax_code": "53111600A9007"
- },
- {
- "description": "A charge for the printing, imprinting, lithographing, mimeographing, photocopying, and similar reproductions of various articles including mailers, catalogs, letterhead, envelopes, business cards, presentation folders, forms, signage, etc. The end result is the transfer of tangible personal property to the customer.",
- "name": "Printing",
- "product_tax_code": "82121500A0000"
- },
- {
- "description": "Service processing payroll checks and tracking payroll data; including printing employees’ payroll checks, pay statements, management reports, tracking payroll taxes, preparing tax returns and producing W-2’s for distribution.",
- "name": "Payroll Services",
- "product_tax_code": "87210202A0000"
- },
- {
- "description": "A charge to repair or restore to operating condition a motor vehicle that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Motor Vehicle Repair",
- "product_tax_code": "25100000A9007"
- },
- {
- "description": "A charge to make customer provided meat suitable for human consumption, typically referred to a butcher or slaughter services.",
- "name": "Meat Processing",
- "product_tax_code": "42447000A0000"
- },
- {
- "description": "A charge to repair or restore to operating condition a machine that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Machine Repair",
- "product_tax_code": "23019007A0000"
- },
- {
- "description": "A charge to provide laundry services to linens and the like. This charge is not for clothing items. The business customer is the owner of the items being cleaned.",
- "name": "Linen Services - Laundry only - items other than clothing",
- "product_tax_code": "91111502A1601"
- },
- {
- "description": "A charge to provide laundry services to clothing. The business customer is the owner of the items being cleaned.",
- "name": "Linen Services - Laundry only",
- "product_tax_code": "91111502A1600"
- },
- {
- "description": "A charge to repair or restore jewelry that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Jewelry Repair",
- "product_tax_code": "54119007A0000"
- },
- {
- "description": "A charge for the wrapping of articles in a box or bag with paper and other decorative additions. The wrapping not linked the purchased of the article(s) and is performed by a party other vendor of the article(s).",
- "name": "Gift Wrapping - separate from purchase of article",
- "product_tax_code": "14111601A9007"
- },
- {
- "description": "A charge for the wrapping of articles in a box or bag with paper and other decorative additions. The charge is separately stated from the article.",
- "name": "Gift Wrapping - in conjunction with purchase of article",
- "product_tax_code": "14111601A0001"
- },
- {
- "description": "A charge to perform an alteration on a item of clothing by a service provider other than vendor of the article. The alteration is not linked to the clothing purchase. Alterations could include hemming of a dress, shortening of pants, adjusting the waistline of a garment, etc.",
- "name": "Garment Alterations- separate from purchase of garment",
- "product_tax_code": "81149000A0000"
- },
- {
- "description": "A charge to perform an alteration on a item of clothing by the vendor of the article. The alteration is separately stated from the clothing, but contracted for at the time of the clothing purchase. Alterations could include hemming of a dress, shortening of pants, adjusting the waistline of a garment, etc.",
- "name": "Garment Alterations- in conjunction with purchase of garment",
- "product_tax_code": "81149000A0001"
- },
- {
- "description": "A separately stated labor charge to cover a piece of furniture previously owned by the customer with new fabric coverings. Any materials transferred as part of the service are separately stated.",
- "name": "Furniture Reupholstering",
- "product_tax_code": "72153614A0000"
- },
- {
- "description": "A charge to create a finished good from materials supplied by the customer. This is a labor only charge to transform a customer's existing property.",
- "name": "Fabrication",
- "product_tax_code": "23839000A0000"
- },
- {
- "description": "E-file services for tax returns",
- "name": "Electronic Filing Service",
- "product_tax_code": "72910000A0000"
- },
- {
- "description": "Private schools, not college or university",
- "name": "Educational Services",
- "product_tax_code": "86132209A0000"
- },
- {
- "description": "A charge to a non-commercial customer for the cleaning or renovating items other than clothing by immersion and agitation, spraying, vaporization, or immersion only, in a volatile, commercially moisture-free solvent or by the use of a volatile or inflammable product. This does not include the use of a self-service coin (or credit card) operated cleaning machine.",
- "name": "Dry Cleaning - items other than clothing",
- "product_tax_code": "91111503A1601"
- },
- {
- "description": "A charge to repair or restore to operating condition computer hardware that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Computer Repair",
- "product_tax_code": "81112300A0000"
- },
- {
- "description": "A charge to clean, wash or wax a motor vehicle, other than a self-service coin (or credit card) operated washing station. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Car Washing",
- "product_tax_code": "81119200A0000"
- },
- {
- "description": "A charge to assemble goods for a purchaser who will later sell the assembled goods to end consumers.",
- "name": "Assembly - prior to final purchase of article",
- "product_tax_code": "93121706A0001"
- },
- {
- "description": "A charge separately stated from the sale of the product itself to bring the article to its finished state and in the condition specified by the buyer.",
- "name": "Assembly - in conjunction with final purchase of article",
- "product_tax_code": "93121706A0000"
- },
- {
- "description": "A charge to repair or restore to operating condition an appliance (dishwasher, washing machine, refrigerator, etc.) that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Appliance Repair",
- "product_tax_code": "52143609A0000"
- },
- {
- "description": "A charge to repair or restore to operating condition an aircraft that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential. Commercial aircraft is excluded.",
- "name": "Aircraft Repair",
- "product_tax_code": "78181800A0000"
- },
- {
- "description": "A charge for the printing, imprinting, or lithographing on any article supplied by the customer. The customer owns the article throughout the process. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Printing - customer supplied articles",
- "product_tax_code": "19009"
- },
- {
- "description": "A charge to a non-commercial customer for the cleaning or renovating clothing by immersion and agitation, spraying, vaporization, or immersion only, in a volatile, commercially moisture-free solvent or by the use of a volatile or inflammable product. This does not include the use of a self-service coin (or credit card) operated cleaning machine.",
- "name": "Dry Cleaning Services",
- "product_tax_code": "19006"
- },
- {
- "description": "A charge to repair or restore tangible personal property that was broken, worn, damaged, defective, or malfunctioning. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Repair Services",
- "product_tax_code": "19007"
- },
- {
- "description": "Food for household pets that is consumed for nutritional value. This code is not intended for food related to working farm animals or animals raised for meat or milk production. This code is intended for retail sales made directly to end consumers.",
- "name": "OTC Pet Food",
- "product_tax_code": "10122100A0000"
- },
- {
- "description": "Food bundle or basket containing food staples combined with candy, with the candy comprising between 25% and 49% of the overall value of the bundle (food comprises 51 to 75%). Note that any candy containing flour should be considered as food (and not candy) when determining bundle percentages.",
- "name": "Food/Candy Bundle - with Candy 25% to 49%",
- "product_tax_code": "50193400A0008"
- },
- {
- "description": "Food bundle or basket containing food staples combined with candy, with the candy comprising between 11% and 24% of the overall value of the bundle (food comprises 76% to 89%). Note that any candy containing flour should be considered as food (and not candy) when determining bundle percentages.",
- "name": "Food/Candy Bundle - with Candy 11% to 24%",
- "product_tax_code": "50193400A0009"
- },
- {
- "description": "Food bundle or basket containing food staples combined with candy, with the candy comprising 10% or less of the overall value of the bundle (food comprises 90% or more). Note that any candy containing flour should be considered as food (and not candy) when determining bundle percentages.",
- "name": "Food/Candy Bundle - with Candy 10% or less",
- "product_tax_code": "50193400A0010"
- },
- {
- "description": "Food bundle or basket containing food staples combined with candy, with the candy comprising 50% or more of the overall value of the bundle (food comprises 50% or less). Note that any candy containing flour should be considered as food (and not candy) when determining bundle percentages.",
- "name": "Food/Candy Bundle - with Candy 50% or more",
- "product_tax_code": "50193400A0007"
- },
- {
- "description": "Male or female condoms and vaginal sponges used to prevent pregnancy and/or exposure to STDs, containing a spermicidal lubricant as indicated by a \"drug facts\" panel or a statement of active ingredients, sold under prescription order of a licensed professional.",
- "name": "Condoms with Spermicide with Prescription",
- "product_tax_code": "53131622A0004"
- },
- {
- "description": "Over-the-Counter emergency contraceptive pills act to prevent pregnancy after intercourse. The contraceptive contains a hormone that prevents ovulation, fertilization, or implantation of an embryo.",
- "name": "Birth Control - Over-the-Counter Oral Contraceptives",
- "product_tax_code": "51350000A0001"
- },
- {
- "description": "An oral medication containing hormones effective in altering the menstrual cycle to eliminate ovulation and prevent pregnancy, available only under prescription order of a licensed professional. Other than preventing pregnancy, hormonal birth control can also be used to treat various conditions, such as Polycystic Ovary Syndrome, Endometriosis, Primary Ovarian Insufficiency, etc.",
- "name": "Birth Control - Prescription Oral Contraceptives",
- "product_tax_code": "51350000A0000"
- },
- {
- "description": "Over-the-Counter emergency contraceptive pills act to prevent pregnancy after intercourse, sold under prescription order of a licensed professional. The contraceptive contains a hormone that prevents ovulation, fertilization, or implantation of an embryo.",
- "name": "Birth Control - Over-the-Counter Oral Contraceptives with Prescription",
- "product_tax_code": "51350000A0002"
- },
- {
- "description": "Barrier based prescription only birth control methods, including the diaphragm and cervical cap that prevent the joining of the sperm and egg, available only under prescription order of a licensed professional.",
- "name": "Birth Control - Prescription non-Oral Contraceptives - Barriers",
- "product_tax_code": "42143103A0000"
- },
- {
- "description": "Hormonal based birth control methods other than the oral pill, including intrauterine devices, injections, skin implants, transdermal patches, and vaginal rings that release a continuous dose of hormones to eliminate ovulation and prevent pregnancy, available only under prescription order of a licensed professional.",
- "name": "Birth Control - Prescription non-Oral Contraceptives - Hormonal",
- "product_tax_code": "51350000A0003"
- },
- {
- "description": "Male or female condoms and vaginal sponges used to prevent pregnancy and/or exposure to STDs, sold under prescription order of a licensed professional.",
- "name": "Condoms with Prescription",
- "product_tax_code": "53131622A0003"
- },
- {
- "description": "Feminine hygiene product designed to absorb the menstrual flow. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Tampons, menstrual cups, pads, liners",
- "product_tax_code": "53131615A0000"
- },
- {
- "description": "Infant washable/reusable cloth diapers.",
- "name": "Clothing - Cloth Diapers",
- "product_tax_code": "53102305A0001"
- },
- {
- "description": "Clothing - Diaper liners",
- "name": "Clothing - Diaper liners",
- "product_tax_code": "53102308A0000"
- },
- {
- "description": "Clothing - Adult diapers",
- "name": "Clothing - Adult diapers",
- "product_tax_code": "53102306A0000"
- },
- {
- "description": "Clothing - Infant diapers",
- "name": "Clothing - Infant diapers",
- "product_tax_code": "53102305A0000"
- },
- {
- "description": "Food and Beverage - Candy containing flour as an ingredient",
- "name": "Food and Beverage - Candy containing flour as an ingredient",
- "product_tax_code": "50161800A0001"
- },
- {
- "description": "Food and Beverage - Food and Food Ingredients for Home Consumption",
- "name": "Food and Beverage - Food and Food Ingredients for Home Consumption",
- "product_tax_code": "50000000A0000"
- },
- {
- "description": "Clothing - Zippers",
- "name": "Clothing - Zippers",
- "product_tax_code": "53141503A0000"
- },
- {
- "description": "Clothing - Gorgets",
- "name": "Clothing - Gorgets",
- "product_tax_code": "53102519A0000"
- },
- {
- "description": "Clothing - Shoulder boards or epaulettes",
- "name": "Clothing - Shoulder boards or epaulettes",
- "product_tax_code": "53102520A0000"
- },
- {
- "description": "Yarn - For use other than fabricating/repairing clothing",
- "name": "Yarn - For use other than fabricating/repairing clothing",
- "product_tax_code": "11151700A0001"
- },
- {
- "description": "Clothing - Snaps",
- "name": "Clothing - Snaps",
- "product_tax_code": "53141506A0000"
- },
- {
- "description": "Clothing - Clasps",
- "name": "Clothing - Clasps",
- "product_tax_code": "53141507A0000"
- },
- {
- "description": "Clothing - Buttons",
- "name": "Clothing - Buttons",
- "product_tax_code": "53141505A0000"
- },
- {
- "description": "Clothing - Clothing - Fabric dye",
- "name": "Clothing - Fabric dye",
- "product_tax_code": "60105810A0000"
- },
- {
- "description": "Clothing - Fabric for use in clothing",
- "name": "Clothing - Fabric for use in clothing",
- "product_tax_code": "11160000A0000"
- },
- {
- "description": "Clothing - Clothing - Yarn",
- "name": "Clothing - Yarn",
- "product_tax_code": "11151700A0000"
- },
- {
- "description": "A lump sum charge where both the downloaded digital products and the service components each are greater than 10% of the bundle.",
- "name": "Digital Products (> 10%) / General Services (> 10%) Bundle",
- "product_tax_code": "55111500A5000"
- },
- {
- "description": "Services provided by a licensed or registered professional in the medical field. Examples: Doctor, dentist, nurse, optometrist, etc.",
- "name": "Medical Professional Services - Physician, Dentist, and similar",
- "product_tax_code": "62139900A0000"
- },
- {
- "description": "The puncturing or penetration of the skin of a person and the insertion of jewelry or other adornment into the opening.",
- "name": "Body Piercing",
- "product_tax_code": "72990190A0000"
- },
- {
- "description": "Cosmetic beauty treatment for the fingernails and toenails. Consists of filing, cutting and shaping and the application of polish.",
- "name": "Manicure Services",
- "product_tax_code": "72310104A0000"
- },
- {
- "description": "Performing tests and research for a particular client in connection with the development of particular products, property, goods or services that the client sells to consumers in the regular course of business.",
- "name": "Marketing Services",
- "product_tax_code": "87420300A0000"
- },
- {
- "description": "Performing surveying and mapping services of the surface of the earth, including the sea floor. These services may include surveying and mapping of areas above or below the surface of the earth, such as the creation of view easements or segregating rights in parcels of land by creating underground utility easements.",
- "name": "Property Surveying Services",
- "product_tax_code": "87130000A0000"
- },
- {
- "description": "Providing a systematic inquiry, examination, or analysis of people, events or documents, to determine the facts of a given situation. The evaluation is submitted in the form of a report or provided as a testimony in legal proceedings. Techniques such as surveillance, background checks, computer searches, fingerprinting, lie detector services, and interviews may be used to gather the information.",
- "name": "Private Investigator Services",
- "product_tax_code": "73810204A0000"
- },
- {
- "description": "The provision of expertise or strategic advice that is presented for consideration and decision-making.",
- "name": "Consulting Services",
- "product_tax_code": "87480000A0000"
- },
- {
- "description": "Services relating to advocating for the passage or defeat of legislation to members or staff of the government.",
- "name": "Lobbying Services",
- "product_tax_code": "87439901A0000"
- },
- {
- "description": "Preparation of materials, written or otherwise, that are designed to influence the general public or other groups by promoting the interests of a service recipient.",
- "name": "Public Relations",
- "product_tax_code": "87430000A0000"
- },
- {
- "description": "Services related to the art and science of designing and building structures for human habitation or use and includes planning, providing preliminary studies, designs, specifications, working drawings and providing for general administration of construction contracts.",
- "name": "Architectural Services",
- "product_tax_code": "87120000A0000"
- },
- {
- "description": "Services provided by a profession trained to apply physical laws and principles of engineering in the design, development, and utilization of machines, materials, instruments, structures, processes, and systems. The services involve any of the following activities: provision of advice, preparation of feasibility studies, preparation of preliminary and final plans and designs, provision of technical services during the construction or installation phase, inspection and evaluation of engineering projects, and related services.",
- "name": "Engineering Services",
- "product_tax_code": "87110000A0000"
- },
- {
- "description": "Services that provide non-medical care and supervision for infant to school-age children or senior citizens.",
- "name": "Childcare Services / Adultcare",
- "product_tax_code": "83510000A0000"
- },
- {
- "description": "Services provided by a facility for overnight care of an animal not related to veterinary care.",
- "name": "Pet Services - Boarding",
- "product_tax_code": "81291000A0001"
- },
- {
- "description": "Services relating to or concerned with the law. Such services include, but are not limited to, representation by an attorney (or other person, when permitted) in an administrative or legal proceeding, legal drafting, paralegal services, legal research services, arbitration, mediation, and court reporting services.",
- "name": "Legal Services",
- "product_tax_code": "81110000A0000"
- },
- {
- "description": "Medical procedure performed on an individual that is directed at improving the individual's appearance and that does not meaningfully promote the proper function of the body or prevent or treat illness or disease.",
- "name": "Cosmetic Medical Procedure",
- "product_tax_code": "80110517A0000"
- },
- {
- "description": "Alarm monitoring involves people using computers, software, and telecommunications to monitor homes and businesses for break-ins, fires, and other unexpected events.",
- "name": "Security - Alarm Services",
- "product_tax_code": "73829901A0000"
- },
- {
- "description": "Services related to protecting persons or their property, preventing the theft of goods, merchandise, or money. Responding to alarm signal device, burglar alarm, television camera, still camera, or a mechanical or electronic device installed or used to prevent or detect burglary, theft, shoplifting, pilferage, losses, or other security measures. Providing management and control of crowds for safety and protection.",
- "name": "Security - Guard Services",
- "product_tax_code": "73810105A0000"
- },
- {
- "description": "Transporting under armed private security guard from one place to another any currency, jewels, stocks, bonds, paintings, or other valuables of any kind in a specially equipped motor vehicle that offers a high degree of security.",
- "name": "Armored Car Services",
- "product_tax_code": "73810101A0000"
- },
- {
- "description": "Services related to providing personnel, on a temporary basis, to perform work or labor under the supervision or control of another.",
- "name": "Temporary Help Services",
- "product_tax_code": "73630103A0000"
- },
- {
- "description": "Services employment agencies provide are finding a job for a job-seeker and finding an employee for an employer.",
- "name": "Employment Services",
- "product_tax_code": "73610000A0000"
- },
- {
- "description": "Services to industrial, commercial or income-producing real property, such as as management, electrical, plumbing, painting and carpentry, provided to income-producing property.",
- "name": "Building Management Services",
- "product_tax_code": "73490000A0000"
- },
- {
- "description": "Services which include, but are not limited to, editing, letter writing, proofreading, resume writing, typing or word processing. Not including court reporting and stenographic services.",
- "name": "Secretarial Services",
- "product_tax_code": "73389903A0000"
- },
- {
- "description": "Services that include typing, taking shorthand, and taking and transcribing dictation for others for a consideration.",
- "name": "Stenographic Services",
- "product_tax_code": "73380200A0000"
- },
- {
- "description": "A process that uses needles and colored ink to permanently put a mark or design on a person’s skin. Also applying permanent make-up, such as eyelining and other permanent colors to enhance the skin of the face, lips, eyelids, and eyebrows.",
- "name": "Tattooing Services",
- "product_tax_code": "72990106A0000"
- },
- {
- "description": "A variety of personal services typically with the purpose of improving health, beauty and relaxation through treatments such as hair, massages and facials.",
- "name": "Spa Services",
- "product_tax_code": "72990201A0000"
- },
- {
- "description": "Services where the use of structured touch, include holding, applying pressure, positioning, and mobilizing soft tissue of the body by manual technique. Note: This does not include medical massage prescribed by a physician.",
- "name": "Massage Services",
- "product_tax_code": "72990200A0000"
- },
- {
- "description": "The measurement, processing and communication of financial information about economic entities including, but is not limited to, financial accounting, management accounting, auditing, cost containment and auditing services, taxation and accounting information systems; excluding general bookkeeping service.",
- "name": "Accounting Services",
- "product_tax_code": "87210200A0000"
- },
- {
- "description": "The training of an animal to obey certain commands.",
- "name": "Pet Services - Obedience Training",
- "product_tax_code": "81291000A0002"
- },
- {
- "description": "Credit monitoring services are companies consumers pay to keep an eye on your credit files. The services notifies one when they see activity in credit files, so one can determine if that activity is a result of action one took or possibly fraudulent",
- "name": "Credit Monitoring Services",
- "product_tax_code": "56145000A0000"
- },
- {
- "description": "Grooming services for an animal such as haircuts, bathing, nail trimming, and flea dips.",
- "name": "Pet Services - Grooming",
- "product_tax_code": "81291000A0000"
- },
- {
- "description": "Debt collection is when a collection agency or company tries to collect past-due debts from borrowers.",
- "name": "Debt Collection Services",
- "product_tax_code": "73229902A0000"
- },
- {
- "description": "A service that arranges introductions, for a fee, for strangers seeking romantic partners or friends. This excludes online dating services.",
- "name": "Dating Services",
- "product_tax_code": "72990301A0000"
- },
- {
- "description": "A service that allows merchants to accept credit cards as well as send credit card payment details to the credit card network. It then forwards the payment authorization back to the acquiring bank.",
- "name": "Credit Card Processing Services",
- "product_tax_code": "52232000A0000"
- },
- {
- "description": "Services for artificial tanning and skin beautification.",
- "name": "Tanning Services",
- "product_tax_code": "72990105A0000"
- },
- {
- "description": "Credit reporting agencies receive various types of information which can be included in their offerings for customers.",
- "name": "Credit Reporting Services",
- "product_tax_code": "73230000A0000"
- },
- {
- "description": "Miscellaneous personal services are generally services that affect the appearance or comfort of people. This does not include haircutting/styling services",
- "name": "Personal Services",
- "product_tax_code": "72990000A0000"
- },
- {
- "description": "The removal of hair from the face or body using chemicals or heat energy.",
- "name": "Electrolysis",
- "product_tax_code": "72310102A0000"
- },
- {
- "description": "Services provided to insurance companies providing insurance to consumers. Examples are loss or damage appraisals, inspections, actuarial services, claims adjustment or processing. Investigations as excluded from this definition.",
- "name": "Insurance Services",
- "product_tax_code": "64110000A0000"
- },
- {
- "description": "An at-home infectious disease test kit that can be sold without a prescription.",
- "name": "Infectious Disease Test - Over-the-Counter",
- "product_tax_code": "41116205A0005"
- },
- {
- "description": "An at-home infectious disease test kit that can only be sold with a prescription.",
- "name": "Infectious Disease Test - Prescription only",
- "product_tax_code": "41116205A0004"
- },
- {
- "description": "Online database information retrieval service (personal or individual)",
- "name": "Online database information retrieval service (personal or individual)",
- "product_tax_code": "81111902A0001"
- },
- {
- "description": "Database information retrieval",
- "name": "Database information retrieval (personal or individual)",
- "product_tax_code": "81111901A0001"
- },
- {
- "description": "Services provided by beauty shops and barber shops, including but not limited to haircutting, hair coloring, shampooing, blow drying, permanents, hair extensions, hair straightening, and hair restorations.",
- "name": "Hairdressing Services",
- "product_tax_code": "19008"
- },
- {
- "description": "Professional services which are not subject to a service-specific tax levy.",
- "name": "Professional Services",
- "product_tax_code": "19005"
- },
- {
- "description": "Online database information retrieval service",
- "name": "Online database information retrieval service",
- "product_tax_code": "81111902A0000"
- },
- {
- "description": "Database information retrieval",
- "name": "Database information retrieval",
- "product_tax_code": "81111901A0000"
- },
- {
- "description": "Cash donation",
- "name": "Cash donation",
- "product_tax_code": "14111803A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, medical grade oyxgen.",
- "name": "Medical Oxygen with Prescription billed to Medicaid",
- "product_tax_code": "42271700A0008"
- },
- {
- "description": "When sold without prescription order of a licensed professional, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged,",
- "name": "Kidney Dialysis Equipment for home use without Prescription",
- "product_tax_code": "42161800A0006"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, shower and bath aids, IV poles, etc.",
- "name": "Durable Medical Equipment for home use with Prescription reimbursed by Medicaid",
- "product_tax_code": "42140000A0005"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged, dysfunctional, or missing. The kidney dialysis machine is an artificial part which augments the natural functioning of the kidneys.",
- "name": "Kidney Dialysis Equipment for home use with Prescription billed to Medicare",
- "product_tax_code": "42161800A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, shower and bath aids, IV poles, etc.",
- "name": "Durable Medical Equipment for home use with Prescription reimbursed by Medicare",
- "product_tax_code": "42140000A0004"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use with Prescription reimbursed by Medicare",
- "product_tax_code": "42271700A0004"
- },
- {
- "description": "When sold under prescription order of a licensed professional, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, shower and bath aids, IV poles, etc.",
- "name": "Durable Medical Equipment for home use with Prescription",
- "product_tax_code": "42140000A0001"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use with Prescription billed to Medicare",
- "product_tax_code": "42271700A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, medical grade oyxgen.",
- "name": "Medical Oxygen with Prescription reimbursed by Medicaid",
- "product_tax_code": "42271700A0010"
- },
- {
- "description": "When sold under prescription order of a licensed professional, medical grade oyxgen.",
- "name": "Medical Oxygen with Prescription",
- "product_tax_code": "42271700A0012"
- },
- {
- "description": "When sold without prescription order of a licensed professional, medical grade oyxgen.",
- "name": "Medical Oxygen without Prescription",
- "product_tax_code": "42271700A0011"
- },
- {
- "description": "Equipment which is primarily and customarily used to provide or increase the ability to move from one place to another, sold without a prescription, and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment without Prescription",
- "product_tax_code": "42211500A0006"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use with Prescription billed to Medicaid",
- "product_tax_code": "42271700A0003"
- },
- {
- "description": "Synthetic or animal-based insulin used as an injectible drug for diabetes patients, sold under prescription order of a licensed professional.",
- "name": "Insulin with Prescription",
- "product_tax_code": "51183603A0000"
- },
- {
- "description": "Devices used by diabetic individuals to monitor sugar levels in the blood, sold under prescription order of a licensed professional.",
- "name": "Blood Glucose Monitoring Devices with Prescription",
- "product_tax_code": "41116201A0000"
- },
- {
- "description": "Devices used by diabetic individuals to monitor sugar levels in the blood, sold without prescription order of a licensed professional.",
- "name": "Blood Glucose Monitoring Devices without Prescription",
- "product_tax_code": "41116201A0001"
- },
- {
- "description": "At home urine-based tests used to detect the presense of various drug substances in an individual.",
- "name": "Drug Testing Kits",
- "product_tax_code": "41116136A0001"
- },
- {
- "description": "Single use hollow needle commonly used with a syringe to inject insulin into the body by diabetic individuals, sold under prescription order of a licensed professional.",
- "name": "Hypodermic Needles/Syringes with prescription - Insulin",
- "product_tax_code": "42142523A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, medical grade oyxgen.",
- "name": "Medical Oxygen with Prescription reimbursed by Medicare",
- "product_tax_code": "42271700A0009"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, medical grade oyxgen.",
- "name": "Medical Oxygen with Prescription billed to Medicare",
- "product_tax_code": "42271700A0007"
- },
- {
- "description": "When sold without prescription order of a licensed professional, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthopedics, ostomy/colostomy devices, etc.",
- "name": "Prosthetic Devices without Prescription",
- "product_tax_code": "42242000A0006"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthotics, orthopedics, ostomy/colostomy devices, catheters, etc.",
- "name": "Prosthetic Devices with Prescription Billed to Medicaid",
- "product_tax_code": "42242000A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthotics, orthopedics, ostomy/colostomy devices, catheters, etc.",
- "name": "Prosthetic Devices with Prescription Billed to Medicare",
- "product_tax_code": "42242000A0002"
- },
- {
- "description": "At home saliva, cheeek swab or blood drop based tests used to detect various genetic markers in an individual.",
- "name": "DNA Testing Kits",
- "product_tax_code": "41116205A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use with Prescription reimbursed by Medicaid",
- "product_tax_code": "42231500A0005"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use with Prescription reimbursed by Medicaid",
- "product_tax_code": "42271700A0005"
- },
- {
- "description": "When sold under prescription order of a licensed professional, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use with Prescription",
- "product_tax_code": "42271700A0001"
- },
- {
- "description": "Synthetic or animal-based insulin used as an injectible drug for diabetes patients, sold without prescription order of a licensed professional.",
- "name": "Insulin without Prescription",
- "product_tax_code": "51183603A0001"
- },
- {
- "description": "Single use hollow needle commonly used with a syringe to inject insulin into the body by diabetic individuals, sold without prescription order of a licensed professional.",
- "name": "Hypodermic Needles/Syringes without prescription - Insulin",
- "product_tax_code": "42142523A0001"
- },
- {
- "description": "When sold without prescription order of a licensed professional, equipment used to administer oxygen directly into the lungs of the patient for the relief of conditions in which the human body experiences an abnormal deficiency or inadequate supply of oxygen. Oxygen equipment means oxygen cylinders, cylinder transport devices, including sheaths and carts, cylinder studs and support devices, regulators, flowmeters, tank wrenches, oxygen concentrators, liquid oxygen base dispensers, liquid oxygen portable dispensers, oxygen tubing, nasal cannulas, face masks, oxygen humidifiers, and oxygen fittings and accessories.",
- "name": "Oxygen Delivery Equipment for home use without Prescription",
- "product_tax_code": "42271700A0006"
- },
- {
- "description": "At home blood-prick based tests used to monitor cholesterol levels in an individual.",
- "name": "Cholesterol Testing Kits",
- "product_tax_code": "41116202A0001"
- },
- {
- "description": "Single use supplies utilized by diabetics in the regular blood sugar monitoring regimen. Includes skin puncture lancets, test strips for blood glucose monitors, visual read test strips, and urine test strips, sold under prescription order of a licensed professional.",
- "name": "Diabetic Testing Supplies - single use - with Prescription",
- "product_tax_code": "41116120A0002"
- },
- {
- "description": "A breast pump is a mechanical device that lactating women use to extract milk from their breasts. They may be manual devices powered by hand or foot movements or automatic devices powered by electricity.",
- "name": "Breast Pumps",
- "product_tax_code": "42231901A0000"
- },
- {
- "description": "At home urine-based tests used to detect pregancy hormone levels.",
- "name": "Pregenacy Testing Kits",
- "product_tax_code": "41116205A0001"
- },
- {
- "description": "When sold under prescription order of a licensed professional, and reimbursed by Medicaid, equipment which is primarily and customarily used to provide or increase the ability to move from one place to another and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and Does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment with Prescription Reimbursed by Medicaid",
- "product_tax_code": "42211500A0005"
- },
- {
- "description": "When sold without prescription order of a licensed professional, a replacement, corrective, or supportive device, worn in the mouth, including dentures, orthodontics, crowns, bridges, etc.",
- "name": "Dental Prosthetics without Prescription",
- "product_tax_code": "42151500A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional, a replacement, corrective, or supportive device, worn in the mouth, including dentures, orthodontics, crowns, bridges, etc.",
- "name": "Dental Prosthetics with Prescription",
- "product_tax_code": "42151500A0001"
- },
- {
- "description": "At home urine-based tests used to detect impending ovulation to assist in pregnancy planning.",
- "name": "Ovulation Testing Kits",
- "product_tax_code": "41116205A0002"
- },
- {
- "description": "When sold without prescription order of a licensed professional, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use without Prescription",
- "product_tax_code": "42231500A0006"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use with Prescription reimbursed by Medicare",
- "product_tax_code": "42231500A0004"
- },
- {
- "description": "When sold without prescription order of a licensed professional, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, IV poles, etc.",
- "name": "Durable Medical Equipment for home use without Prescription",
- "product_tax_code": "42140000A0006"
- },
- {
- "description": "Male or female condoms used to prevent pregnancy or exposure to STDs, containing a spermicidal lubricant as indicated by a \"drug facts\" panel or a statement of active ingredients.",
- "name": "Condoms with Spermicide",
- "product_tax_code": "53131622A0001"
- },
- {
- "description": "Single use supplies utilized by diabetics in the regular blood sugar monitoring regimen. Includes skin puncture lancets, test strips for blood glucose monitors, visual read test strips, and urine test strips.",
- "name": "Diabetic Testing Supplies - single use - without Prescription",
- "product_tax_code": "41116120A0001"
- },
- {
- "description": "An electronic device that clips onto a patient's finger to measure heart rate and oxygen saturation in his or her red blood cells.",
- "name": "Pulse Oximeter",
- "product_tax_code": "42181801A0000"
- },
- {
- "description": "When sold under prescription order of a licensed professional, equipment which is primarily and customarily used to provide or increase the ability to move from one place to another and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and Does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment with Prescription",
- "product_tax_code": "42211500A0001"
- },
- {
- "description": "When sold under prescription order of a licensed professional, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthotics, orthopedics, ostomy/colostomy devices, catheters, etc.",
- "name": "Prosthetic Devices with Prescription",
- "product_tax_code": "42242000A0001"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use with Prescription billed to Medicare",
- "product_tax_code": "42231500A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, shower and bath aids, IV poles, etc.",
- "name": "Durable Medical Equipment for home use with Prescription billed to Medicaid",
- "product_tax_code": "42140000A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthotics, orthopedics, ostomy/colostomy devices, catheters, etc.",
- "name": "Prosthetic Devices with Prescription Reimbursed by Medicare",
- "product_tax_code": "42242000A0004"
- },
- {
- "description": "When sold under prescription order of a licensed professional, and billed to Medicare, equipment which is primarily and customarily used to provide or increase the ability to move from one place to another and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and Does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment with Prescription Billed to Medicare",
- "product_tax_code": "42211500A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional, and billed to Medicaid, equipment which is primarily and customarily used to provide or increase the ability to move from one place to another and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and Does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment with Prescription Billed to Medicaid",
- "product_tax_code": "42211500A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicare, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged, dysfunctional, or missing. The kidney dialysis machine is an artificial part which augments the natural functioning of the kidneys.",
- "name": "Kidney Dialysis Equipment for home use with Prescription reimbursed by Medicare",
- "product_tax_code": "42161800A0004"
- },
- {
- "description": "At home digital or manual (aneroid) sphygmomanometers, also known as a blood pressure meter, blood pressure monitor, or blood pressure gauge, are devices used to measure blood pressure, composed of an inflatable cuff to collapse and then release the artery under the cuff in a controlled manner.",
- "name": "Blood Pressure Testing Devices",
- "product_tax_code": "42181600A0001"
- },
- {
- "description": "A topical preparation containing a spermicidal lubricant to prevent pregnancy as indicated by a \"drug facts\" panel or a statement of active ingredients.",
- "name": "Contraceptive Ointments",
- "product_tax_code": "53131622A0002"
- },
- {
- "description": "Male or female condoms used to prevent pregnacy or exposure to STDs.",
- "name": "Condoms",
- "product_tax_code": "53131622A0000"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicare, equipment that: can withstand repeated use; is primarily and customarily used to serve a medical purpose; generally is not useful to a person in the absence of illness or injury; and is not worn in or on the body. Home use means the equipment is sold to an individual for use at home, regardless of where the individual resides. Examples include hospital beds, commode chairs, bed pans, shower and bath aids, IV poles, etc.",
- "name": "Durable Medical Equipment for home use with Prescription billed to Medicare",
- "product_tax_code": "42140000A0002"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, a replacement, corrective, or supportive device, worn on or in the body to: Artificially replace a missing portion of the body; Prevent or correct physical deformity or malfunction; or Support a weak or deformed portion of the body. Worn in or on the body means that the item is implanted or attached so that it becomes part of the body, or is carried by the body and does not hinder the mobility of the individual. Examples include artificial limbs, pacemakers, orthotics, orthopedics, ostomy/colostomy devices, catheters, etc.",
- "name": "Prosthetic Devices with Prescription Reimbursed by Medicaid",
- "product_tax_code": "42242000A0005"
- },
- {
- "description": "When sold under prescription order of a licensed professional, and reimbursed by Medicare, equipment which is primarily and customarily used to provide or increase the ability to move from one place to another and which is appropriate for use either in a home or a motor vehicle; Is not generally used by persons with normal mobility; and Does not include any motor vehicle or equipment on a motor vehicle normally provided by a motor vehicle manufacturer. Examples include wheelchairs, crutches, canes, walkers, chair lifts, etc.",
- "name": "Mobility Enhancing Equipment with Prescription Reimbursed by Medicare",
- "product_tax_code": "42211500A0004"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use with prescription billed to Medicaid",
- "product_tax_code": "42231500A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional, nutritional tube feeding equipment including button-style feeding tubes, standard G-tubes, NG-tubes, extension sets, adapters, feeding pumps, feeding pump delivery sets.",
- "name": "Enteral Feeding Equipment for home use with Prescription",
- "product_tax_code": "42231500A0001"
- },
- {
- "description": "When sold under prescription order of a licensed professional and billed directly to Medicaid, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged, dysfunctional, or missing. The kidney dialysis machine is an artificial part which augments the natural functioning of the kidneys.",
- "name": "Kidney Dialysis Equipment for home use with Prescription billed to Medicaid",
- "product_tax_code": "42161800A0003"
- },
- {
- "description": "When sold under prescription order of a licensed professional and reimbursed by Medicaid, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged, dysfunctional, or missing. The kidney dialysis machine is an artificial part which augments the natural functioning of the kidneys.",
- "name": "Kidney Dialysis Equipment for home use with Prescription and reimbursed by Medicaid",
- "product_tax_code": "42161800A0005"
- },
- {
- "description": "When sold under prescription order of a licensed professional, a machine used that filters a patient's blood to remove excess water and waste products when the kidneys are damaged, dysfunctional, or missing. The kidney dialysis machine is an artificial part which augments the natural functioning of the kidneys.",
- "name": "Kidney Dialysis Equipment for home use with Prescription",
- "product_tax_code": "42161800A0001"
- },
- {
- "description": "Handbags, Purses",
- "name": "Handbags, Purses",
- "product_tax_code": "53121600A0000"
- },
- {
- "description": "Video Gaming Console - Fixed",
- "name": "Video Gaming Console - Fixed",
- "product_tax_code": "52161557A0000"
- },
- {
- "description": "Video Cameras",
- "name": "Video Cameras",
- "product_tax_code": "45121515A0000"
- },
- {
- "description": "Portable audio equipment that records digital music for playback",
- "name": "Digital Music Players",
- "product_tax_code": "52161543A0000"
- },
- {
- "description": "A type of consumer electronic device used to play vinyl recordings.",
- "name": "Audio Turntables",
- "product_tax_code": "52161548A0000"
- },
- {
- "description": "Video Gaming Console - Portable",
- "name": "Video Gaming Console - Portable",
- "product_tax_code": "52161558A0000"
- },
- {
- "description": "A framed display designed to display preloaded digital images (jpeg or any digital image format). Has slots for flash memory cards and/or an interface for digital photo camera connection.",
- "name": "Digital Picture Frames",
- "product_tax_code": "52161549A0000"
- },
- {
- "description": "Digital Cameras",
- "name": "Digital Cameras",
- "product_tax_code": "45121504A0000"
- },
- {
- "description": "Mobile Phones",
- "name": "Mobile Phones",
- "product_tax_code": "43191501A0000"
- },
- {
- "description": "A digital wristwatch that provides many other features besides timekeeping. Like a smartphone, a smartwatch has a touchscreen display, which allows you to perform actions by tapping or swiping on the screen. Smartwatches include allow access to apps, similar to apps for smartphones and tablets.",
- "name": "Watches - Smart",
- "product_tax_code": "54111500A0001"
- },
- {
- "description": "A bicycle helmet that is NOT marketed and labeled as being intended for youth.",
- "name": "Bicycle Helmets - Adult",
- "product_tax_code": "46181704A0003"
- },
- {
- "description": "A bicycle helmet marketed and labeled as being intended for youth.",
- "name": "Bicycle Helmets - Youth",
- "product_tax_code": "46181704A0002"
- },
- {
- "description": "Luggage",
- "name": "Luggage",
- "product_tax_code": "53121500A0000"
- },
- {
- "description": "Clothing - Sleep or eye mask",
- "name": "Clothing - Sleep or eye mask",
- "product_tax_code": "53102607A0000"
- },
- {
- "description": "Clothing - Pocket protectors",
- "name": "Clothing - Pocket protectors",
- "product_tax_code": "53102514A0000"
- },
- {
- "description": "Clothing - Button covers",
- "name": "Clothing - Button covers",
- "product_tax_code": "53102515A0000"
- },
- {
- "description": "Shoe Inserts/Insoles",
- "name": "Clothing - Shoe Inserts/Insoles",
- "product_tax_code": "46182208A0000"
- },
- {
- "description": "Aprons - Household/Kitchen",
- "name": "Clothing - Aprons - Household/Kitchen",
- "product_tax_code": "46181501A0002"
- },
- {
- "description": "Hunting Vests",
- "name": "Clothing - Hunting Vests",
- "product_tax_code": "53103100A0003"
- },
- {
- "description": "Clothing apparel/uniforms that are specific to the training and competition of various martial arts.",
- "name": "Clothing - Martial Arts Attire",
- "product_tax_code": "53102717A0001"
- },
- {
- "description": "Clothing - Umbrellas",
- "name": "Clothing - Umbrellas",
- "product_tax_code": "53102505A0000"
- },
- {
- "description": "Briefcases",
- "name": "Briefcases",
- "product_tax_code": "53121701A0000"
- },
- {
- "description": "Wallets",
- "name": "Wallets",
- "product_tax_code": "53121600A0001"
- },
- {
- "description": "Wristwatch timepieces",
- "name": "Watches",
- "product_tax_code": "54111500A0000"
- },
- {
- "description": "Jewelry",
- "name": "Jewelry",
- "product_tax_code": "54100000A0000"
- },
- {
- "description": "Non-prescription sunglasses",
- "name": "Sunglasses - Non-Rx",
- "product_tax_code": "42142905A0001"
- },
- {
- "description": "Wigs, Hairpieces, Hair extensions",
- "name": "Clothing - Wigs, Hairpieces, Hair extensions",
- "product_tax_code": "53102500A0002"
- },
- {
- "description": "Hair notions, hair clips, barrettes, hair bows, hair nets, etc.",
- "name": "Clothing - Hair Accessories",
- "product_tax_code": "53102500A0001"
- },
- {
- "description": "Clothing - Headbands",
- "name": "Clothing - Headbands",
- "product_tax_code": "53102513A0000"
- },
- {
- "description": "These supplies contain medication such as an antibiotic ointment. They are a labeled with a \"drug facts\" panel or a statement of active ingredients. A wound care supply is defined as an item that is applied directly to or inside a wound to absorb wound drainage, protect healing tissue, maintain a moist or dry wound environment (as appropriate), or prevent bacterial contamination. Examples include bandages, dressings, gauze, medical tape. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Wound Care Supplies - Bandages, Dressings, Gauze - Medicated",
- "product_tax_code": "42311514A0000"
- },
- {
- "description": "A wound care supply is defined as an item that is applied directly to or inside a wound to absorb wound drainage, protect healing tissue, maintain a moist or dry wound environment (as appropriate), or prevent bacterial contamination. Examples include bandages, dressings, gauze, medical tape. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Wound Care Supplies - Bandages, Dressings, Gauze",
- "product_tax_code": "42311500A0001"
- },
- {
- "description": "Toothpaste containing \"drug facts\" panel or a statement of active ingredients. These products do contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Toothpaste",
- "product_tax_code": "53131502A0000"
- },
- {
- "description": "Disposable moistened cleansing wipes - non medicated. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Baby Wipes/Cleansing Wipes",
- "product_tax_code": "53131624A0000"
- },
- {
- "description": "Toilet Paper. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Toilet Paper",
- "product_tax_code": "14111704A0000"
- },
- {
- "description": "A lotion, spray, gel, foam, stick or other topical product that absorbs or reflects some of the sun's ultraviolet (UV) radiation and thus helps protect against sunburn. Sunscreen contains a \"drug facts\" label or statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Sunscreen",
- "product_tax_code": "53131609A0000"
- },
- {
- "description": "Soaps, body washes, shower gels for personal hygiene containing antibacterial. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Soaps - Antibacterial",
- "product_tax_code": "53131608A0001"
- },
- {
- "description": "Over-the-counter nicotine replacement products, including patches, gum, lozenges, sprays and inhalers. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Smoking Cessation Products",
- "product_tax_code": "51143218A0000"
- },
- {
- "description": "Lotions, moisturizers, creams, powders, sprays, etc that promote optimal skin health. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Skin Care Products- Medicated",
- "product_tax_code": "51241200A0001"
- },
- {
- "description": "A hair care product for cleansing the hair/scalp, with anti-dandruff active ingredients. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Shampoo - medicated (anti-dandruff)",
- "product_tax_code": "53131628A0001"
- },
- {
- "description": "A multi-purpose skin protectorant and topical ointment. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Petroleum Jelly",
- "product_tax_code": "53131641A0000"
- },
- {
- "description": "An over-the-counter drug via RX is a substance that contains a label identifying it as a drug and including a \"drug facts\" panel or a statement of active ingredients, that can be obtained without a prescription, but is sold under prescription order of a licensed professional. A drug can be intended for internal (ingestible, implant, injectable) or external (topical) application to the human body. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Over-the-Counter Drugs via Prescription",
- "product_tax_code": "51030"
- },
- {
- "description": "Flexible adhesive strips that attach over the bridge of the nose to lift the sides of the nose, opening the nasal passages to provide relief for congestion and snoring. The products are drug free and contain no active drug ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Nasal Breathing Strips",
- "product_tax_code": "42312402A0001"
- },
- {
- "description": "Therapeutic mouthwash, having active ingredients (such as antiseptic, or flouride) intended to help control or reduce conditions like bad breath, gingivitis, plaque, and tooth decay. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Mouthwash - Therapeutic",
- "product_tax_code": "53131501A0000"
- },
- {
- "description": "Multiple use medical thermometers for oral, temporal/forehead, or rectal body temperature diagnostics. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Medical Thermometers - Reusable",
- "product_tax_code": "42182200A0002"
- },
- {
- "description": "One-time use medical thermometers for oral, temporal/forehead, or rectal body temperature diagnostics. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Medical Thermometers - Disposable",
- "product_tax_code": "42182200A0001"
- },
- {
- "description": "Masks designed for one-time use to protect the wearer from contamination of breathable particles. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Medical Masks",
- "product_tax_code": "42131713A0001"
- },
- {
- "description": "A medicated skin protectorant for the lips. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Lip Balm - Medicated",
- "product_tax_code": "53131630A0001"
- },
- {
- "description": "A skin protectorant for the lips. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Lip Balm",
- "product_tax_code": "53131630A0000"
- },
- {
- "description": "Artificial devices to correct or alleviate hearing deficiencies, sold under prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hearing Aids with Prescription",
- "product_tax_code": "42211705A0000"
- },
- {
- "description": "Artificial deives to correct or alleviate hearing deficiencies, sold without a prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hearing Aids without Prescription",
- "product_tax_code": "42211705A0001"
- },
- {
- "description": "Batteries specifically labeled and designed to operate hearing aid devices, sold under prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hearing Aid Batteries with Prescription",
- "product_tax_code": "26111710A0001"
- },
- {
- "description": "Batteries specifically labeled and designed to operate hearing aid devices, sold without a prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hearing Aid Batteries without Prescription",
- "product_tax_code": "26111710A0002"
- },
- {
- "description": "A liquid, gel, foam, or wipe generally used to decrease infectious agents on the hands. Alcohol-based versions typically contain some combination of isopropyl alcohol, ethanol (ethyl alcohol), or n-propanol. Alcohol-free products are generally based on disinfectants, or on antimicrobial agents. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hand Sanitizers",
- "product_tax_code": "53131626A0000"
- },
- {
- "description": "Topical foams, creams, gels, etc that prevent hair loss and promote hair regrowth. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hair Loss Products",
- "product_tax_code": "51182001A0001"
- },
- {
- "description": "A collection of mixed supplies and equipment that is used to give medical treatment, often housed in durable plastic boxes, fabric pouches or in wall mounted cabinets. Exempt or low rated qualifying medicinal items (eg. OTC drugs) make up 51% or more of the value of the kit. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "First Aid Kits - 51% or more medicinal items",
- "product_tax_code": "42172001A0002"
- },
- {
- "description": "A collection of mixed supplies and equipment that is used to give medical treatment, often housed in durable plastic boxes, fabric pouches or in wall mounted cabinets. Exempt or low rated qualifying medicinal items (eg. OTC drugs) make up 50% or less of the value of the kit. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "First Aid Kits - 50% or less medicinal items",
- "product_tax_code": "42172001A0001"
- },
- {
- "description": "Over-the-counter antifungal creams, ointments or suppositories to treat yeast infections, containing a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Feminine Yeast Treatments",
- "product_tax_code": "51302300A0001"
- },
- {
- "description": "Vaginal cleaning products include douches and wipes with medication such as an antiseptic, containing a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Feminine Cleansing Solutions - Medicated",
- "product_tax_code": "53131615A0002"
- },
- {
- "description": "Vaginal cleaning products include douches and wipes. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Feminine Cleansing Solutions",
- "product_tax_code": "53131615A0001"
- },
- {
- "description": "Single use disposable gloves (latex, nitrile, vinyl, etc) that while appropriate for multiple uses, have an application in a first aid or medical setting. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Disposable Gloves",
- "product_tax_code": "42132203A0000"
- },
- {
- "description": "Personal under-arm deodorants/antiperspirants. These products do contain a \"drug facts\" panel or a statement of active ingredients, typically aluminum. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Deodorant/Antiperspirant",
- "product_tax_code": "53131606A0000"
- },
- {
- "description": "Denture adhesives are pastes, powders or adhesive pads that may be placed in/on dentures to help them stay in place. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Denture creams/adhesives",
- "product_tax_code": "53131510A0000"
- },
- {
- "description": "Toothbrushes. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Toothbrushes",
- "product_tax_code": "53131503A0000"
- },
- {
- "description": "Dental Floss/picks. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Dental Floss/picks",
- "product_tax_code": "53131504A0000"
- },
- {
- "description": "Single use cotton balls or swabs for multi-purpose use other than applying medicines and cleaning wounds, due to not being sterile. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cotton Balls/Swabs - Unsterile",
- "product_tax_code": "42141500A0002"
- },
- {
- "description": "Single use cotton balls or swabs for application of antiseptics and medications and to cleanse scratches, cuts or minor wounds. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cotton Balls/Swabs - Sterile",
- "product_tax_code": "42141500A0001"
- },
- {
- "description": "Corrective lenses, eyeglasses, sold under prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Corrective Lenses, Eyeglasses with Prescription",
- "product_tax_code": "42142900A0001"
- },
- {
- "description": "Corrective lenses, including eyeglasses and contact lenses, sold without a prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Corrective Lenses without Prescription",
- "product_tax_code": "42142900A0002"
- },
- {
- "description": "Liquid solution for lubricating/rewetting, but not disinfecting, contact lenses. This solution is applied directly to the lens, rather then inserted into the eye. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Contact Lens Lubricating Solutions - For lens",
- "product_tax_code": "42142914A0001"
- },
- {
- "description": "Liquid solution for lubricating/rewetting, but not disinfecting, contact lenses. This solution is applied directly to the eye. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Contact Lens Lubricating Solutions - For eyes",
- "product_tax_code": "42142914A0002"
- },
- {
- "description": "Contact lenses, sold under prescription order of a licensed professional. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Contact Lenses with Prescription",
- "product_tax_code": "42142913A0000"
- },
- {
- "description": "Liquid solution for cleaning and disinfecting contact lenses. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Contact Lens Disinfecting Solutions",
- "product_tax_code": "42142914A0000"
- },
- {
- "description": "A reusable pain management supply that includes artificial ice packs, gel packs, heat wraps, etc used for pain relief. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cold or Hot Therapy Packs - Reusable",
- "product_tax_code": "42142100A0002"
- },
- {
- "description": "A heating pad is a pad used for warming of parts of the body in order to manage pain. Types of heating pads include electrical, chemical and hot water bottles. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Heating Pads",
- "product_tax_code": "42142100A0001"
- },
- {
- "description": "A single use pain management supply that includes artificial ice packs, gel packs, heat wraps, etc used for pain relief. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cold or Hot Therapy Packs - Disposable - Medicated",
- "product_tax_code": "42142100A0004"
- },
- {
- "description": "A single use pain management supply that includes artificial ice packs, gel packs, heat wraps, etc used for pain relief. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cold or Hot Therapy Packs - Disposable",
- "product_tax_code": "42142100A0003"
- },
- {
- "description": "Baby powder is an astringent powder used for preventing diaper rash, as a spray, and for other cosmetic uses. It may be composed of talcum (in which case it is also called talcum powder) or corn starch. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Baby Powder",
- "product_tax_code": "53131649A0001"
- },
- {
- "description": "Baby oil is an inert (typically mineral) oil for the purpose of keeping skin soft and supple. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Baby Oil",
- "product_tax_code": "51241900A0001"
- },
- {
- "description": "A cosmetic foam or gel used for shaving preparation. The purpose of shaving cream is to soften the hair by providing lubrication. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Shaving Creams",
- "product_tax_code": "53131611A0000"
- },
- {
- "description": "Personal under-arm deodorants/antiperspirants containing natural ingredients and/or ingredients that are not considered drugs. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Deodorant - Natural or no active ingredients",
- "product_tax_code": "53131606A0001"
- },
- {
- "description": "A hair care product for cleansing the hair/scalp. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Shampoo",
- "product_tax_code": "53131628A0000"
- },
- {
- "description": "Various surfactant preparations to improve cleaning, enhance the enjoyment of bathing, and serve as a vehicle for cosmetic agents. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Bubble Bath, Bath Salts/Oils/Crystals",
- "product_tax_code": "53131612A0001"
- },
- {
- "description": "Cosmetic mouthwash may temporarily control bad breath and leave behind a pleasant taste, but has no chemical or biological application beyond their temporary benefit. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Mouthwash - Cosmetic",
- "product_tax_code": "53131501A0001"
- },
- {
- "description": "Teeth whitening gels, rinse, strips, trays, etc containing bleaching agents. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Teeth Whitening Kits",
- "product_tax_code": "42151506A0000"
- },
- {
- "description": "A hair care product typically applied and rinsed after shampooing that is used to improve the feel, appearance and manageability of hair. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Conditioner - Hair",
- "product_tax_code": "53131628A0002"
- },
- {
- "description": "Depilatories are cosmetic preparations used to remove hair from the skin. Chemical depilatories are available in gel, cream, lotion, aerosol, roll-on, and powder forms. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hair Removal Products",
- "product_tax_code": "53131623A0000"
- },
- {
- "description": "Breath spray is a product sprayed into the mouth and breath strips dissolve in the mouth for the purpose of eliminating halitosis. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Breath Spray/dissolvable strips",
- "product_tax_code": "53131509A0000"
- },
- {
- "description": "Lotions, moisturizers, creams, powders, sprays, etc that promote optimal skin health. These products do not contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Skin Care Products",
- "product_tax_code": "51241200A0002"
- },
- {
- "description": "Liquid drops to be placed inside the ear canal to reduce the symptoms of an ear ache, or to act as an ear drying aid, or to loosen, cleanse, and aid in the removal of ear wax. These products contain a \"drug facts\" panel or a statement of active ingredients. Examples include Ear Ache, Swimmers' Ears, and Ear Wax removal drops. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Ear Drops - Medicated",
- "product_tax_code": "51241000A0001"
- },
- {
- "description": "Topical medicated solutions for treating skin acne. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Acne Treatments",
- "product_tax_code": "51241400A0001"
- },
- {
- "description": "A skin cream forming a protective barrier to help heal and soothe diaper rash discomfort. These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Diaper Cream",
- "product_tax_code": "51241859A0001"
- },
- {
- "description": "A liquid solution typically used as a topical antiseptic. The products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Isopropyl (Rubbing) Alcohol",
- "product_tax_code": "51471901A0000"
- },
- {
- "description": "Hydrogen peroxide is a mild antiseptic used on the skin to prevent infection of minor cuts, scrapes, and burns. It may also be used as a mouth rinse to help remove mucus or to relieve minor mouth irritation (e.g., due to canker/cold sores, gingivitis). These products contain a \"drug facts\" panel or a statement of active ingredients. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Hydrogen Peroxide",
- "product_tax_code": "51473503A0000"
- },
- {
- "description": "Articles intended to be rubbed, poured, sprinkled, or sprayed on, introduced into, or otherwise applied to the human body or any part thereof for beautifying, promoting attractiveness, or altering the appearance. This category supports only the following items: Acrylic fingernail glue, Acrylic fingernails, Artificial eyelashes, Blush, Bronzer, Body glitter, Concealer, Eyelash glue, Finger/toenail decorations, Finger/toenail polish, Nail polish remover, Hair coloring, Hair mousse/gel, Hair oil, Hair spray, Hair relaxer, Hair wave treatment, Hair wax, Lip gloss, Lip liner, Lipstick, Liquid foundation, Makeup, Mascara, Nail polish remover, Powder foundation, Cologne, Perfume. This code is intended for sales directly to end consumers that are NOT healthcare providers.",
- "name": "Cosmetics - Beautifying",
- "product_tax_code": "53131619A0001"
- },
- {
- "description": "Power cords",
- "name": "Power cords",
- "product_tax_code": "26121636A0000"
- },
- {
- "description": "Archery accessories including quivers, releases, arrow shafts, armguards, hunting belts, bow parts, cleaning products, mounted safety equipment, scopes, sights, hunting slings, string wax, targets, target throwers, etc.",
- "name": "Archery Accessories",
- "product_tax_code": "49181602A0002"
- },
- {
- "description": "Landscape soil, mulch, compost - residential",
- "name": "Landscape Soil, Mulch, Compost - Residential",
- "product_tax_code": "11121700A0001"
- },
- {
- "description": "Firearms, limited to pistols, revolvers, rifles with a barrel no greater than an internal diameter of .50 caliber or a shotguns of 10 gauge or smaller.",
- "name": "Firearms",
- "product_tax_code": "46101500A0001"
- },
- {
- "description": "Firearm accessories including repair parts, cleaning products, holsters, mounted safety equipment, choke tubes, scopes, shooting tripod/bipod/monopod, shooting bags/pouches, sights, etc.",
- "name": "Firearm Accessories",
- "product_tax_code": "46101506A0001"
- },
- {
- "description": "Portable fuel container",
- "name": "Portable Fuel Container",
- "product_tax_code": "24111808A0001"
- },
- {
- "description": "Hard and soft cases designed specifically for firearms equipment",
- "name": "Gun Cases",
- "product_tax_code": "46101801A0000"
- },
- {
- "description": "Primary archery equipment including bows, crossbow, and bow strings.",
- "name": "Archery Equipment",
- "product_tax_code": "49181602A0001"
- },
- {
- "description": "Hard and soft cases designed specifically for archery equipment.",
- "name": "Archery Cases",
- "product_tax_code": "46101801A0001"
- },
- {
- "description": "Protective earmuffs to muffle the sound of gunfire.",
- "name": "Hearing Protection Earmuffs",
- "product_tax_code": "46181902A0001"
- },
- {
- "description": "Ammunition for firearms with a barrel no greater than an internal diameter of .50 caliber or a shotgun of 10 gauge or smaller., including bullets, shotgun shells, and gunpowder.",
- "name": "Ammunition",
- "product_tax_code": "46101600A0001"
- },
- {
- "description": "Bedclothes items including sheets, pillow cases, bedspreads, comforters, blankets, throws, duvet covers, pillow shams, bed skirts, mattress pad, mattress toppers, and pillows.",
- "name": "Bedding",
- "product_tax_code": "52121500A0000"
- },
- {
- "description": "Towels used for individual drying of persons, including bath towels, beach towels, wash cloths, hand towels, fact towels, sport towels, etc.",
- "name": "Bath towels",
- "product_tax_code": "52121700A0000"
- },
- {
- "description": "WaterSense labeled urinals.",
- "name": "Urinals - WaterSense",
- "product_tax_code": "30181506A0000"
- },
- {
- "description": "WaterSense labeled toilets.",
- "name": "Toilets - WaterSense",
- "product_tax_code": "30181505A0000"
- },
- {
- "description": "WaterSense labeled irrigation controllers, which act like a thermostat for your sprinkler system telling it when to turn on and off, use local weather and landscape conditions to tailor watering schedules to actual conditions on the site.",
- "name": "Irrigation Controls - WaterSense",
- "product_tax_code": "21102503A0001"
- },
- {
- "description": "Ceiling Fans carrying an Energy Star rating.",
- "name": "Ceiling fans - Energy Star",
- "product_tax_code": "40101609A0000"
- },
- {
- "description": "Standard incandescent light bulbs carrying an Energy Star rating.",
- "name": "Incandescent Light Bulbs - Energy Star",
- "product_tax_code": "39101612A0001"
- },
- {
- "description": "Compact Fluorescent light (CFL) bulbs carrying an Energy Star rating.",
- "name": "Compact Fluorescent Light Bulbs - Energy Star",
- "product_tax_code": "39101619A0001"
- },
- {
- "description": "Domestic appliance carrying an Energy Star Rating which reduces and maintains the level of humidity in the air.",
- "name": "Dehumidifier - Energy Star",
- "product_tax_code": "40101902A0000"
- },
- {
- "description": "Domestic air conditioning (central or room) systems carrying Energy Star rating.",
- "name": "Air conditioners - Energy Star",
- "product_tax_code": "40101701A0000"
- },
- {
- "description": "Artificial ice, blue ice, ice packs, reusable ice",
- "name": "Artificial Ice",
- "product_tax_code": "24121512A0000"
- },
- {
- "description": "A port replicator is an attachment for a notebook computer that allows a number of devices such as a printer, large monitor, and keyboard to be simultaneously connected.",
- "name": "Port Replicators",
- "product_tax_code": "43211603A0000"
- },
- {
- "description": "Computer Mouse/Pointing Devices",
- "name": "Computer Mouse/Pointing Devices",
- "product_tax_code": "43211708A0000"
- },
- {
- "description": "Storage drives, hard drives, Zip drives, etc.",
- "name": "Computer Drives",
- "product_tax_code": "43201800A0001"
- },
- {
- "description": "An in home programmable thermostat, such as a WiFi enabled smart thermostat, carrying an Energy Star rating.",
- "name": "Programmable Wall Thermostat - Energy Star",
- "product_tax_code": "41112209A0001"
- },
- {
- "description": "Domestic gas or oil boilers for space or water heating carrying an Energy Star rating.",
- "name": "Boilers - Energy Star",
- "product_tax_code": "40102004A0001"
- },
- {
- "description": "Domestic water heater carrying Energy Star rating.",
- "name": "Water heater - Energy Star",
- "product_tax_code": "40101825A0000"
- },
- {
- "description": "Domestic freezers carrying Energy Star rating.",
- "name": "Freezers- Energy Star",
- "product_tax_code": "52141506A0000"
- },
- {
- "description": "Domestic air source heat pumps carrying Energy Star rating.",
- "name": "Heat Pumps - Energy Star",
- "product_tax_code": "40101806A0000"
- },
- {
- "description": "Domestic gas or oil furnaces carrying an Energy Star rating.",
- "name": "Furnaces - Energy Star",
- "product_tax_code": "40101805A0000"
- },
- {
- "description": "Plywood, window film, storm shutters, hurricane shutters or other materials specifically designed to protect windows.",
- "name": "Storm shutters/window protection devices",
- "product_tax_code": "30151801A0001"
- },
- {
- "description": "Smoke Detectors",
- "name": "Smoke Detectors",
- "product_tax_code": "46191501A0000"
- },
- {
- "description": "Mobile phone charging device/cord",
- "name": "Mobile Phone Charging Device/cord",
- "product_tax_code": "43191501A0002"
- },
- {
- "description": "A webcam is a video camera that feeds or streams an image or video in real time to or through a computer to a computer network, such as the Internet. Webcams are typically small cameras that sit on a desk, attach to a user's monitor, or are built into the hardware",
- "name": "Web Camera",
- "product_tax_code": "45121520A0000"
- },
- {
- "description": "A sound card is an expansion component used in computers to receive and send audio.",
- "name": "Sound Cards",
- "product_tax_code": "43201502A0000"
- },
- {
- "description": "Computer Speakers",
- "name": "Computer Speakers",
- "product_tax_code": "43211607A0000"
- },
- {
- "description": "Computer Microphones",
- "name": "Computer Microphones",
- "product_tax_code": "43211719A0000"
- },
- {
- "description": "A docking station is a hardware frame and set of electrical connection interfaces that enable a notebook computer to effectively serve as a desktop computer.",
- "name": "Docking Stations",
- "product_tax_code": "43211602A0000"
- },
- {
- "description": "Computer Batteries",
- "name": "Computer Batteries",
- "product_tax_code": "26111711A0001"
- },
- {
- "description": "Computer Monitor/Displays",
- "name": "Computer Monitor/Displays",
- "product_tax_code": "43211900A0000"
- },
- {
- "description": "Computer Keyboards",
- "name": "Computer Keyboards",
- "product_tax_code": "43211706A0000"
- },
- {
- "description": "Printer Ink",
- "name": "Printer Ink",
- "product_tax_code": "44103105A0000"
- },
- {
- "description": "Printer Paper",
- "name": "Printer Paper",
- "product_tax_code": "14111507A0000"
- },
- {
- "description": "Non-electric can opener",
- "name": "Can opener - manual",
- "product_tax_code": "52151605A0001"
- },
- {
- "description": "Sheet music - Student",
- "name": "Sheet music - Student",
- "product_tax_code": "55101514A0000"
- },
- {
- "description": "Musical instruments - Student",
- "name": "Musical instruments - Student",
- "product_tax_code": "60130000A0001"
- },
- {
- "description": "Reference printed material commonly used by a student in a course of study as a reference and to learn the subject being taught.",
- "name": "Dictionaries/Thesauruses",
- "product_tax_code": "55101526A0001"
- },
- {
- "description": "An item commonly used by a student in a course of study for artwork. This category is limited to the following items...clay and glazes, paints, paintbrushes for artwork, sketch and drawing pads, watercolors.",
- "name": "School Art Supplies",
- "product_tax_code": "60121200A0001"
- },
- {
- "description": "A calendar based notebook to aid in outlining one's daily appointments, classes, activities, etc.",
- "name": "Daily Planners",
- "product_tax_code": "44112004A0001"
- },
- {
- "description": "Portable self-powered or battery powered radio, two-way radio, weatherband radio.",
- "name": "Portable Radios",
- "product_tax_code": "43191510A0000"
- },
- {
- "description": "Single or multi-pack AA, AAA, c, D, 6-volt or 9-volt batteries, excluding automobile or boat batteries.",
- "name": "Alkaline Batteries",
- "product_tax_code": "26111702A0000"
- },
- {
- "description": "Routers",
- "name": "Routers",
- "product_tax_code": "43222609A0000"
- },
- {
- "description": "Removable storage media such as compact disks, flash drives, thumb drives, flash memory cards.",
- "name": "Computer Storage Media",
- "product_tax_code": "43202000A0000"
- },
- {
- "description": "Computer Printer",
- "name": "Computer Printer",
- "product_tax_code": "43212100A0001"
- },
- {
- "description": "Portable self-powered or battery powered light sources, including flashlights, lanterns, emergency glow sticks or light sticks.",
- "name": "Portable Light Sources",
- "product_tax_code": "39111610A0000"
- },
- {
- "description": "Canned software on tangible media that is used for non-recreational purposes, such as Antivirus, Database, Educational, Financial, Word processing, etc.",
- "name": "Software - Prewritten, tangible media - Non-recreational",
- "product_tax_code": "43230000A1101"
- },
- {
- "description": "Personal computers, including laptops, tablets, desktops.",
- "name": "Personal Computers",
- "product_tax_code": "43211500A0001"
- },
- {
- "description": "A device that joins pages of paper or similar material by fastening a thin metal staple through the sheets and folding the ends underneath.",
- "name": "Staplers/Staples",
- "product_tax_code": "44121615A0000"
- },
- {
- "description": "Pins/tacks to secure papers, pictures, calendars, etc. to bulletin boards, walls, etc.",
- "name": "Push pins/tacks",
- "product_tax_code": "44122106A0000"
- },
- {
- "description": "Bags/packs designed to carry students' books during the school day. This category does not include backpags for traveling, hiking, camping, etc.",
- "name": "Bookbags/Backpacks - Student",
- "product_tax_code": "53121603A0001"
- },
- {
- "description": "Ground anchor systems and tie down kits for securing property against severe weather.",
- "name": "Ground Anchor Systems and Tie-down Kits",
- "product_tax_code": "31162108A0000"
- },
- {
- "description": "An expansion card that allows the computer to send graphical information to a video display device such as a monitor, TV, or projector. Video cards are often used by gamers in place of integrated graphics due to their extra processing power and video ram.",
- "name": "Video/Graphics Card",
- "product_tax_code": "43201401A0000"
- },
- {
- "description": "Scanners",
- "name": "Scanners",
- "product_tax_code": "43211711A0000"
- },
- {
- "description": "Modems",
- "name": "Modems",
- "product_tax_code": "43222628A0000"
- },
- {
- "description": "A map that could be used by a student in a course of study as a reference and to learn the subject being taught.",
- "name": "Maps - Student",
- "product_tax_code": "60103410A0001"
- },
- {
- "description": "Tarps, plastic sheeting, plastic drop cloths, waterproof sheeting.",
- "name": "Tarpaulins and Weatherproof Sheeting",
- "product_tax_code": "24141506A0000"
- },
- {
- "description": "Portable generator used to provide light or communications or power appliances during a power outage.",
- "name": "Portable Generator",
- "product_tax_code": "26111604A0001"
- },
- {
- "description": "Headphones/Earbuds",
- "name": "Headphones/Earbuds",
- "product_tax_code": "52161514A0000"
- },
- {
- "description": "A portable electronic device for reading digital books and periodicals.",
- "name": "E-Book Readers",
- "product_tax_code": "43211519A0000"
- },
- {
- "description": "Mobile phone batteries",
- "name": "Mobile Phone Batteries",
- "product_tax_code": "43191501A0001"
- },
- {
- "description": "A globe that could be used by a student in a course of study as a reference and to learn the subject being taught.",
- "name": "Globes - Student",
- "product_tax_code": "60104414A0001"
- },
- {
- "description": "Domestic standard size refrigerators carrying Energy Star rating.",
- "name": "Refrigerators - Energy Star",
- "product_tax_code": "52141501A0000"
- },
- {
- "description": "Non-electric food or beverage cooler.",
- "name": "Food Storage Cooler",
- "product_tax_code": "52152002A0001"
- },
- {
- "description": "A motherboard is the physical component in a computer that contains the computer's basic circuitry and other components",
- "name": "Motherboards",
- "product_tax_code": "43201513A0000"
- },
- {
- "description": "Calculators",
- "name": "Calculators",
- "product_tax_code": "44101807A0000"
- },
- {
- "description": "Axes/Hatchets",
- "name": "Axes/Hatchets",
- "product_tax_code": "27112005A0000"
- },
- {
- "description": "Water conserving products are for conserving or retaining groundwater; recharging water tables; or decreasing ambient air temperature, and so limiting water evaporation. Examples include soil sufactants, a soaker or drip-irrigation hose, a moisture control for a sprinkler or irrigation system, a rain barrel or an alternative rain and moisture collection system, a permeable ground cover surface that allows water to reach underground basins, aquifers or water collection points.",
- "name": "Water Conserving Products",
- "product_tax_code": "21102500A0001"
- },
- {
- "description": "Domestic clothes drying appliances carrying Energy Star rating.",
- "name": "Clothes drying machine - Energy Star",
- "product_tax_code": "52141602A0000"
- },
- {
- "description": "Software - Prewritten, electronic delivery - Business Use",
- "name": "Software - Prewritten, electronic delivery - Business Use",
- "product_tax_code": "43230000A9200"
- },
- {
- "description": "Software - Custom, electronic delivery - Business Use",
- "name": "Software - Custom, electronic delivery - Business Use",
- "product_tax_code": "43230000A9201"
- },
- {
- "description": "Food and Beverage - Sugar and Sugar Substitutes",
- "name": "Food and Beverage - Sugar and Sugar Substitutes",
- "product_tax_code": "50161900A0000"
- },
- {
- "description": "Food and Beverage - Eggs and egg products",
- "name": "Food and Beverage - Eggs and egg products",
- "product_tax_code": "50131600A0000"
- },
- {
- "description": "Food and Beverage - Coffee, coffee substitutes and tea",
- "name": "Food and Beverage - Coffee, coffee substitutes and tea",
- "product_tax_code": "50201700A0000"
- },
- {
- "description": "Food and Beverage - Candy",
- "name": "Food and Beverage - Candy",
- "product_tax_code": "50161800A0000"
- },
- {
- "description": "Food and Beverage - Butter, Margarine, Shortening and Cooking Oils",
- "name": "Food and Beverage - Butter, Margarine, Shortening and Cooking Oils",
- "product_tax_code": "50151500A0000"
- },
- {
- "description": "Clothing - Facial shields",
- "name": "Clothing - Facial shields",
- "product_tax_code": "46181702A0001"
- },
- {
- "description": "Clothing - Hard hats",
- "name": "Clothing - Hard hats",
- "product_tax_code": "46181701A0001"
- },
- {
- "description": "Clothing - Cleanroom footwear",
- "name": "Clothing - Cleanroom footwear",
- "product_tax_code": "46181603A0001"
- },
- {
- "description": "Clothing - Fire retardant footwear",
- "name": "Clothing - Fire retardant footwear",
- "product_tax_code": "46181601A0001"
- },
- {
- "description": "Clothing - Protective pants",
- "name": "Clothing - Protective pants",
- "product_tax_code": "46181527A0001"
- },
- {
- "description": "Clothing - Garters",
- "name": "Clothing - Garters",
- "product_tax_code": "53102509A0000"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered electronically (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional, custom, electronic delivery (support services only)",
- "product_tax_code": "81112200A2222"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for prewritten software including items delivered by load and leave",
- "name": "Software maintenance and support - Mandatory, prewritten, load and leave delivery",
- "product_tax_code": "81112200A1310"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for prewritten software including items delivered electronically",
- "name": "Software maintenance and support - Mandatory, prewritten, electronic delivery",
- "product_tax_code": "81112200A1210"
- },
- {
- "description": "Electronic software documentation or user manuals - For prewritten software & delivered electronically",
- "name": "Electronic software documentation or user manuals - Prewritten, electronic delivery",
- "product_tax_code": "55111601A1200"
- },
- {
- "description": "Clothing - Fur Ear muffs or scarves",
- "name": "Clothing - Fur Ear muffs or scarves",
- "product_tax_code": "53102502A0001"
- },
- {
- "description": "Clothing - Safety glasses",
- "name": "Clothing - Safety glasses",
- "product_tax_code": "46181802A0001"
- },
- {
- "description": "Software - Prewritten & delivered on tangible media",
- "name": "Software - Prewritten, tangible media",
- "product_tax_code": "43230000A1100"
- },
- {
- "description": "Mainframe administration services\r\n",
- "name": "Mainframe administration services\r\n",
- "product_tax_code": "81111802A0000"
- },
- {
- "description": "Co-location service",
- "name": "Co-location service",
- "product_tax_code": "81111814A0000"
- },
- {
- "description": "Information management system for mine action IMSMA\r\n",
- "name": "Information management system for mine action IMSMA\r\n",
- "product_tax_code": "81111710A0000"
- },
- {
- "description": "Local area network LAN maintenance or support",
- "name": "Local area network LAN maintenance or support",
- "product_tax_code": "81111803A0000"
- },
- {
- "description": "Data center services",
- "name": "Data center services",
- "product_tax_code": "81112003A0000"
- },
- {
- "description": "Wide area network WAN maintenance or support",
- "name": "Wide area network WAN maintenance or support",
- "product_tax_code": "81111804A0000"
- },
- {
- "description": "Electronic data interchange EDI design\r\n",
- "name": "Electronic data interchange EDI design\r\n",
- "product_tax_code": "81111703A0000"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered on tangible media (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, tangible media (support services only)",
- "product_tax_code": "81112200A1122"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered on tangible media (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, tangible media (incl. updates/upgrades)",
- "product_tax_code": "81112200A1121"
- },
- {
- "description": "Clothing - Safety sleeves",
- "name": "Clothing - Safety sleeves",
- "product_tax_code": "46181516A0001"
- },
- {
- "description": "Clothing - Fire retardant apparel",
- "name": "Clothing - Fire retardant apparel",
- "product_tax_code": "46181508A0001"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered by load and leave (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, load and leave delivery (support services only)",
- "product_tax_code": "81112200A1322"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered by load and leave (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, load and leave delivery (incl. updates/upgrades)",
- "product_tax_code": "81112200A1321"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered electronically (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, electronic delivery (support services only)",
- "product_tax_code": "81112200A1222"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered on tangible media (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered on tangible media (includes support services only - no updates/upgrades)\r\n",
- "product_tax_code": "81112200A2122"
- },
- {
- "description": "Clothing - Protective ponchos",
- "name": "Clothing - Protective ponchos",
- "product_tax_code": "46181506A0001"
- },
- {
- "description": "Clothing - Protective gloves",
- "name": "Clothing - Protective gloves",
- "product_tax_code": "46181504A0001"
- },
- {
- "description": "Clothing - Synthetic Fur Vest",
- "name": "Clothing - Synthetic Fur Vest",
- "product_tax_code": "53103100A0002"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered on tangible media (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, custom, tangible media (incl. updates/upgrades)",
- "product_tax_code": "81112200A2121"
- },
- {
- "description": "Computer graphics service\r\n",
- "name": "Computer graphics service\r\n",
- "product_tax_code": "81111512A0000"
- },
- {
- "description": "Proprietary or licensed systems maintenance or support",
- "name": "Proprietary or licensed systems maintenance or support",
- "product_tax_code": "81111805A0000"
- },
- {
- "description": "Software - Prewritten & delivered electronically",
- "name": "Software - Prewritten, electronic delivery",
- "product_tax_code": "43230000A1200"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for prewritten software including items delivered electronically (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, prewritten, electronic delivery (incl. updates/upgrades)",
- "product_tax_code": "81112200A1221"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered by load and leave (includes support services only - no updates/upgrades)",
- "name": "Software maintenance and support - Optional, custom, load and leave delivery (support services only)",
- "product_tax_code": "81112200A2322"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered by load and leave (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, custom, load and leave delivery (incl. updates/upgrades)",
- "product_tax_code": "81112200A2321"
- },
- {
- "description": "Software maintenance and support - Optional maintenance and support charges for custom software including items delivered electronically (includes software updates/upgrades)",
- "name": "Software maintenance and support - Optional, custom, electronic delivery (incl. updates/upgrades)",
- "product_tax_code": "81112200A2221"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for custom software including items delivered on tangible media",
- "name": "Software maintenance and support - Mandatory, custom, tangible media",
- "product_tax_code": "81112200A2110"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for custom software including items delivered electronically",
- "name": "Software maintenance and support - Mandatory, custom, electronic delivery",
- "product_tax_code": "81112200A2210"
- },
- {
- "description": "Food and Beverage - Fish and seafood",
- "name": "Food and Beverage - Fish and seafood",
- "product_tax_code": "50121500A0000"
- },
- {
- "description": "Food and Beverage - Ice cubes",
- "name": "Food and Beverage - Ice cubes",
- "product_tax_code": "50202302A0000"
- },
- {
- "description": "Food and Beverage - Cooking Ingredients",
- "name": "Food and Beverage - Cooking Ingredients",
- "product_tax_code": "50181700A0000"
- },
- {
- "description": "Food and Beverage - Cocoa and Cocoa products",
- "name": "Food and Beverage - Cocoa and Cocoa products",
- "product_tax_code": "50161511A0000"
- },
- {
- "description": "Food and Beverage - Baby foods and formulas",
- "name": "Food and Beverage - Baby foods and formulas",
- "product_tax_code": "42231800A0000"
- },
- {
- "description": "Clothing - Hazardous material protective footwear",
- "name": "Clothing - Hazardous material protective footwear",
- "product_tax_code": "46181602A0001"
- },
- {
- "description": "Clothing - Welder gloves",
- "name": "Clothing - Welder gloves",
- "product_tax_code": "46181540A0001"
- },
- {
- "description": "Clothing - Protective shirts",
- "name": "Clothing - Protective shirts",
- "product_tax_code": "46181526A0001"
- },
- {
- "description": "Gift Cards",
- "name": "Gift Cards",
- "product_tax_code": "14111803A0001"
- },
- {
- "description": "Clothing - Leg protectors",
- "name": "Clothing - Leg protectors",
- "product_tax_code": "46181520A0001"
- },
- {
- "description": "Clothing - Protective coveralls",
- "name": "Clothing - Protective coveralls",
- "product_tax_code": "46181503A0001"
- },
- {
- "description": "Internet or intranet client application development services\r\n",
- "name": "Internet or intranet client application development services\r\n",
- "product_tax_code": "81111509A0000"
- },
- {
- "description": "Database design\r\n",
- "name": "Database design\r\n",
- "product_tax_code": "81111704A0000"
- },
- {
- "description": "Computer programmers\r\n",
- "name": "Computer programmers\r\n",
- "product_tax_code": "81111600A0000"
- },
- {
- "description": "Clothing - Synthetic Fur Hat",
- "name": "Clothing - Synthetic Fur Hat",
- "product_tax_code": "53102504A0002"
- },
- {
- "description": "System or application programming management service\r\n",
- "name": "System or application programming management service\r\n",
- "product_tax_code": "81111511A0000"
- },
- {
- "description": "Food and Beverage - Fruit",
- "name": "Food and Beverage - Fruit",
- "product_tax_code": "50300000A0000"
- },
- {
- "description": "Food and Beverage - Vegetables",
- "name": "Food and Beverage - Vegetables",
- "product_tax_code": "50400000A0000"
- },
- {
- "description": "Food and Beverage - Dried fruit, unsweetened",
- "name": "Food and Beverage - Dried fruit, unsweetened",
- "product_tax_code": "50320000A0000"
- },
- {
- "description": "Food and Beverage - Snack Foods",
- "name": "Food and Beverage - Snack Foods",
- "product_tax_code": "50192100A0000"
- },
- {
- "description": "Food and Beverage - Processed Nuts and Seeds",
- "name": "Food and Beverage - Nuts and seeds that have been processed or treated by salting, spicing, smoking, roasting, or other means",
- "product_tax_code": "50101716A0001"
- },
- {
- "description": "Food and Beverage - Non-Alcoholic Beer, Wine",
- "name": "Food and Beverage - Non-Alcoholic Beer, Wine",
- "product_tax_code": "50202300A0001"
- },
- {
- "description": "Food and Beverage - Ice Cream, sold in container less than one pint",
- "name": "Food and Beverage - Ice Cream, sold in container less than one pint",
- "product_tax_code": "50192304A0000"
- },
- {
- "description": "Food and Beverage - Alcoholic beverages - Spirits",
- "name": "Food and Beverage - Alcoholic beverages - Spirits",
- "product_tax_code": "50202206A0000"
- },
- {
- "description": "Food and Beverage - Wine",
- "name": "Food and Beverage - Alcoholic beverages - Wine",
- "product_tax_code": "50202203A0000"
- },
- {
- "description": "Electronic content bundle - Delivered electronically with less than permanent rights of usage and streamed",
- "name": "Electronic content bundle - Delivered electronically with less than permanent rights of usage and streamed",
- "product_tax_code": "55111500A9220"
- },
- {
- "description": "Clothing - Welding masks",
- "name": "Clothing - Welding masks",
- "product_tax_code": "46181703A0001"
- },
- {
- "description": "Clothing - Protective wear dispenser",
- "name": "Clothing - Protective wear dispenser",
- "product_tax_code": "46181553A0001"
- },
- {
- "description": "Clothing - Anti cut gloves",
- "name": "Clothing - Anti cut gloves",
- "product_tax_code": "46181536A0001"
- },
- {
- "description": "Clothing - Reflective apparel or accessories",
- "name": "Clothing - Reflective apparel or accessories",
- "product_tax_code": "46181531A0001"
- },
- {
- "description": "Clothing - Heat resistant clothing",
- "name": "Clothing - Heat resistant clothing",
- "product_tax_code": "46181518A0001"
- },
- {
- "description": "Clothing - Cleanroom apparel",
- "name": "Clothing - Cleanroom apparel",
- "product_tax_code": "46181512A0001"
- },
- {
- "description": "Clothing - Hazardous material protective apparel",
- "name": "Clothing - Hazardous material protective apparel",
- "product_tax_code": "46181509A0001"
- },
- {
- "description": "Clothing - Safety vests",
- "name": "Clothing - Safety vests",
- "product_tax_code": "46181507A0001"
- },
- {
- "description": "Clothing - Protective knee pads",
- "name": "Clothing - Protective knee pads",
- "product_tax_code": "46181505A0001"
- },
- {
- "description": "Clothing - Bullet proof vests",
- "name": "Clothing - Bullet proof vests",
- "product_tax_code": "46181502A0001"
- },
- {
- "description": "Clothing - Vest or waistcoats",
- "name": "Clothing - Vest or waistcoats",
- "product_tax_code": "53103100A0000"
- },
- {
- "description": "Clothing - Prisoner uniform",
- "name": "Clothing - Prisoner uniform",
- "product_tax_code": "53102716A0000"
- },
- {
- "description": "Clothing - Paramedic uniforms",
- "name": "Clothing - Paramedic uniforms",
- "product_tax_code": "53102712A0000"
- },
- {
- "description": "Clothing - Ambulance officers uniforms",
- "name": "Clothing - Ambulance officers uniforms",
- "product_tax_code": "53102709A0000"
- },
- {
- "description": "Clothing - Doctors coat",
- "name": "Clothing - Doctors coat",
- "product_tax_code": "53102707A0000"
- },
- {
- "description": "Clothing - Sweat bands",
- "name": "Clothing - Sweat bands",
- "product_tax_code": "53102506A0000"
- },
- {
- "description": "Clothing - Helmet parts or accessories",
- "name": "Clothing - Helmet parts or accessories",
- "product_tax_code": "46181706A0001"
- },
- {
- "description": "Clothing - Fur Vest",
- "name": "Clothing - Fur Vest",
- "product_tax_code": "53103100A0001"
- },
- {
- "description": "Clothing - Fur Gloves",
- "name": "Clothing - Fur Gloves",
- "product_tax_code": "53102503A0001"
- },
- {
- "description": "Clothing - Motorcycle helmets",
- "name": "Clothing - Motorcycle helmets",
- "product_tax_code": "46181705A0001"
- },
- {
- "description": "Operating system programming services\r\n",
- "name": "Operating system programming services\r\n",
- "product_tax_code": "81111505A0000"
- },
- {
- "description": "Local area network communications design\r\n",
- "name": "Local area network communications design\r\n",
- "product_tax_code": "81111702A0000"
- },
- {
- "description": "Clothing - Eye shields",
- "name": "Clothing - Eye shields",
- "product_tax_code": "46181803A0001"
- },
- {
- "description": "Clothing - Welders helmet",
- "name": "Clothing - Welders helmet",
- "product_tax_code": "46181711A0001"
- },
- {
- "description": "Clothing - Footwear covers",
- "name": "Clothing - Footwear covers",
- "product_tax_code": "46181606A0001"
- },
- {
- "description": "Clothing - Cooling vest",
- "name": "Clothing - Cooling vest",
- "product_tax_code": "46181554A0001"
- },
- {
- "description": "Clothing - Protective mesh jacket",
- "name": "Clothing - Protective mesh jacket",
- "product_tax_code": "46181551A0001"
- },
- {
- "description": "Clothing - Protective scarf",
- "name": "Clothing - Protective scarf",
- "product_tax_code": "46181550A0001"
- },
- {
- "description": "Clothing - Neck gaitor",
- "name": "Clothing - Neck gaitor",
- "product_tax_code": "46181549A0001"
- },
- {
- "description": "Clothing - Welder bib",
- "name": "Clothing - Welder bib",
- "product_tax_code": "46181548A0001"
- },
- {
- "description": "Clothing - Waterproof cap cover",
- "name": "Clothing - Waterproof cap cover",
- "product_tax_code": "46181547A0001"
- },
- {
- "description": "Clothing - Waterproof suit",
- "name": "Clothing - Waterproof suit",
- "product_tax_code": "46181545A0001"
- },
- {
- "description": "Clothing - Waterproof trousers or pants",
- "name": "Clothing - Waterproof trousers or pants",
- "product_tax_code": "46181544A0001"
- },
- {
- "description": "Clothing - Protective mittens",
- "name": "Clothing - Protective mittens",
- "product_tax_code": "46181542A0001"
- },
- {
- "description": "Clothing - Chemical resistant gloves",
- "name": "Clothing - Chemical resistant gloves",
- "product_tax_code": "46181541A0001"
- },
- {
- "description": "Clothing - Anti vibratory gloves",
- "name": "Clothing - Anti vibratory gloves",
- "product_tax_code": "46181539A0001"
- },
- {
- "description": "Clothing - Thermal gloves",
- "name": "Clothing - Thermal gloves",
- "product_tax_code": "46181538A0001"
- },
- {
- "description": "Clothing - Insulated gloves",
- "name": "Clothing - Insulated gloves",
- "product_tax_code": "46181537A0001"
- },
- {
- "description": "Clothing - Protective socks or hosiery",
- "name": "Clothing - Protective socks or hosiery",
- "product_tax_code": "46181535A0001"
- },
- {
- "description": "Clothing - Protective wristbands",
- "name": "Clothing - Protective wristbands",
- "product_tax_code": "46181534A0001"
- },
- {
- "description": "Clothing - Protective coats",
- "name": "Clothing - Protective coats",
- "product_tax_code": "46181533A0001"
- },
- {
- "description": "Clothing - Insulated clothing for cold environments",
- "name": "Clothing - Insulated clothing for cold environments",
- "product_tax_code": "46181529A0001"
- },
- {
- "description": "Clothing - Protective frock",
- "name": "Clothing - Protective frock",
- "product_tax_code": "46181528A0001"
- },
- {
- "description": "Clothing - Safety hoods",
- "name": "Clothing - Safety hoods",
- "product_tax_code": "46181522A0001"
- },
- {
- "description": "Clothing - Insulated or flotation suits",
- "name": "Clothing - Insulated or flotation suits",
- "product_tax_code": "46181517A0001"
- },
- {
- "description": "Clothing - Elbow protectors",
- "name": "Clothing - Elbow protectors",
- "product_tax_code": "46181514A0001"
- },
- {
- "description": "Clothing - Protective aprons",
- "name": "Clothing - Protective aprons",
- "product_tax_code": "46181501A0001"
- },
- {
- "description": "Clothing - Shoes",
- "name": "Clothing - Shoes",
- "product_tax_code": "53111600A0000"
- },
- {
- "description": "Clothing - Athletic wear",
- "name": "Clothing - Athletic wear",
- "product_tax_code": "53102900A0000"
- },
- {
- "description": "Clothing - Folkloric clothing",
- "name": "Clothing - Folkloric clothing",
- "product_tax_code": "53102200A0000"
- },
- {
- "description": "Clothing - Overalls or coveralls",
- "name": "Clothing - Overalls or coveralls",
- "product_tax_code": "53102100A0000"
- },
- {
- "description": "Clothing - Dresses or skirts or saris or kimonos",
- "name": "Clothing - Dresses or skirts or saris or kimonos",
- "product_tax_code": "53102000A0000"
- },
- {
- "description": "Clothing - Suits",
- "name": "Clothing - Suits",
- "product_tax_code": "53101900A0000"
- },
- {
- "description": "Clothing - Sport uniform",
- "name": "Clothing - Sport uniform",
- "product_tax_code": "53102717A0000"
- },
- {
- "description": "Clothing - Judicial robe",
- "name": "Clothing - Judicial robe",
- "product_tax_code": "53102714A0000"
- },
- {
- "description": "Clothing - Ushers uniforms",
- "name": "Clothing - Ushers uniforms",
- "product_tax_code": "53102713A0000"
- },
- {
- "description": "Clothing - Nurses uniforms",
- "name": "Clothing - Nurses uniforms",
- "product_tax_code": "53102708A0000"
- },
- {
- "description": "Clothing - School uniforms",
- "name": "Clothing - School uniforms",
- "product_tax_code": "53102705A0000"
- },
- {
- "description": "Clothing - Institutional food preparation or service attire",
- "name": "Clothing - Institutional food preparation or service attire",
- "product_tax_code": "53102704A0000"
- },
- {
- "description": "Clothing - Police uniforms",
- "name": "Clothing - Police uniforms",
- "product_tax_code": "53102703A0000"
- },
- {
- "description": "Clothing - Customs uniforms",
- "name": "Clothing - Customs uniforms",
- "product_tax_code": "53102702A0000"
- },
- {
- "description": "Clothing - Bandannas",
- "name": "Clothing - Bandannas",
- "product_tax_code": "53102511A0000"
- },
- {
- "description": "Clothing - Armbands",
- "name": "Clothing - Armbands",
- "product_tax_code": "53102508A0000"
- },
- {
- "description": "Clothing - Caps",
- "name": "Clothing - Caps",
- "product_tax_code": "53102516A0000"
- },
- {
- "description": "Clothing - Protective finger cots",
- "name": "Clothing - Protective finger cots",
- "product_tax_code": "46181530A0001"
- },
- {
- "description": "Application programming services\r\n",
- "name": "Application programming services\r\n",
- "product_tax_code": "81111504A0000"
- },
- {
- "description": "Application implementation services\r\n",
- "name": "Application implementation services\r\n",
- "product_tax_code": "81111508A0000"
- },
- {
- "description": "Clothing - Synthetic Fur Ear muffs or scarves",
- "name": "Clothing - Synthetic Fur Ear muffs or scarves",
- "product_tax_code": "53102502A0002"
- },
- {
- "description": "Clothing - Fur Poncho or Cape",
- "name": "Clothing - Fur Poncho or Cape",
- "product_tax_code": "53101806A0001"
- },
- {
- "description": "Food and Beverage - Vitamins and Supplements - labeled with nutritional facts",
- "name": "Food and Beverage - Vitamins and Supplements - labeled with nutritional facts",
- "product_tax_code": "50501500A0001"
- },
- {
- "description": "Food and Beverage - Nuts and seeds",
- "name": "Food and Beverage - Nuts and seeds",
- "product_tax_code": "50101716A0000"
- },
- {
- "description": "Food and Beverage - Milk Substitutes",
- "name": "Food and Beverage - Milk Substitutes",
- "product_tax_code": "50151515A9000"
- },
- {
- "description": "Food and Beverage - Milk and milk products",
- "name": "Food and Beverage - Milk and milk products",
- "product_tax_code": "50131700A0000"
- },
- {
- "description": "Food and Beverage - Cheese",
- "name": "Food and Beverage - Cheese",
- "product_tax_code": "50131800A0000"
- },
- {
- "description": "Clothing - Sandals",
- "name": "Clothing - Sandals",
- "product_tax_code": "53111800A0000"
- },
- {
- "description": "Clothing - Pajamas or nightshirts or robes",
- "name": "Clothing - Pajamas or nightshirts or robes",
- "product_tax_code": "53102600A0000"
- },
- {
- "description": "Clothing - Sweaters",
- "name": "Clothing - Sweaters",
- "product_tax_code": "53101700A0000"
- },
- {
- "description": "Clothing - Slacks or trousers or shorts",
- "name": "Clothing - Slacks or trousers or shorts",
- "product_tax_code": "53101500A0000"
- },
- {
- "description": "Clothing - Firefighter uniform",
- "name": "Clothing - Firefighter uniform",
- "product_tax_code": "53102718A0000"
- },
- {
- "description": "Clothing - Salon smocks",
- "name": "Clothing - Salon smocks",
- "product_tax_code": "53102711A0000"
- },
- {
- "description": "Clothing - Military uniforms",
- "name": "Clothing - Military uniforms",
- "product_tax_code": "53102701A0000"
- },
- {
- "description": "Clothing - Heel pads",
- "name": "Clothing - Heel pads",
- "product_tax_code": "53112003A0000"
- },
- {
- "description": "Clothing - Shoelaces",
- "name": "Clothing - Shoelaces",
- "product_tax_code": "53112002A0000"
- },
- {
- "description": "Clothing - Infant swaddles or buntings or receiving blankets",
- "name": "Clothing - Infant swaddles or buntings or receiving blankets",
- "product_tax_code": "53102608A0000"
- },
- {
- "description": "Clothing - Hats",
- "name": "Clothing - Hats",
- "product_tax_code": "53102503A0000"
- },
- {
- "description": "Clothing - Ties or scarves or mufflers",
- "name": "Clothing - Ties or scarves or mufflers",
- "product_tax_code": "53102502A0000"
- },
- {
- "description": "Clothing - Belts or suspenders",
- "name": "Clothing - Belts or suspenders",
- "product_tax_code": "53102501A0000"
- },
- {
- "description": "Clothing - Tights",
- "name": "Clothing - Tights",
- "product_tax_code": "53102404A0000"
- },
- {
- "description": "Clothing - Disposable youth training pants",
- "name": "Clothing - Disposable youth training pants",
- "product_tax_code": "53102311A0000"
- },
- {
- "description": "Clothing - Undershirts",
- "name": "Clothing - Undershirts",
- "product_tax_code": "53102301A0000"
- },
- {
- "description": "Clothing - Insulated cold weather shoe",
- "name": "Clothing - Insulated cold weather shoe",
- "product_tax_code": "46181610A0000"
- },
- {
- "description": "Food and Beverage - Grains, Rice, Cereal",
- "name": "Food and Beverage - Grains, Rice, Cereal",
- "product_tax_code": "50221200A0000"
- },
- {
- "description": "Clothing - Shirts",
- "name": "Clothing - Shirts",
- "product_tax_code": "53101600A0000"
- },
- {
- "description": "Clothing - Safety boots",
- "name": "Clothing - Safety boots",
- "product_tax_code": "46181604A0000"
- },
- {
- "description": "Clothing - Shin guards",
- "name": "Clothing - Shin guards",
- "product_tax_code": "49161525A0001"
- },
- {
- "description": "Clothing - Athletic supporter",
- "name": "Clothing - Athletic supporter",
- "product_tax_code": "49161517A0001"
- },
- {
- "description": "Clothing - Cleated or spiked shoes",
- "name": "Clothing - Cleated or spiked shoes",
- "product_tax_code": "53111900A0002"
- },
- {
- "description": "Wide area network communications design\r\n",
- "name": "Wide area network communications design\r\n\r\n",
- "product_tax_code": "81111701A0000"
- },
- {
- "description": "Systems integration design\r\n",
- "name": "Systems integration design\r\n",
- "product_tax_code": "81111503A0000"
- },
- {
- "description": "Clothing - Bridal Gown",
- "name": "Clothing - Bridal Gown",
- "product_tax_code": "53101801A0004"
- },
- {
- "description": "Clothing - Waterproof cap",
- "name": "Clothing - Waterproof cap",
- "product_tax_code": "46181546A0000"
- },
- {
- "description": "Food and Beverage - Yogurt",
- "name": "Food and Beverage - Yogurt",
- "product_tax_code": "50131800A0001"
- },
- {
- "description": "Food and Beverage - Nut Butters",
- "name": "Food and Beverage - Nut Butters",
- "product_tax_code": "50480000A9000"
- },
- {
- "description": "Food and Beverage - Jams and Jellies",
- "name": "Food and Beverage - Jams and Jellies",
- "product_tax_code": "50192401A0000"
- },
- {
- "description": "Food and Beverage - Honey, Maple Syrup",
- "name": "Food and Beverage - Honey, Maple Syrup",
- "product_tax_code": "50161509A0000"
- },
- {
- "description": "Food and Beverage - Foods for Immediate Consumption",
- "name": "Food and Beverage - Foods for Immediate Consumption",
- "product_tax_code": "90100000A0001"
- },
- {
- "description": "Food and Beverage - Bread and Flour Products",
- "name": "Food and Beverage - Bread and Flour Products",
- "product_tax_code": "50180000A0000"
- },
- {
- "description": "Clothing - Overshoes",
- "name": "Clothing - Overshoes",
- "product_tax_code": "53112000A0000"
- },
- {
- "description": "Clothing - Athletic footwear",
- "name": "Clothing - Athletic footwear",
- "product_tax_code": "53111900A0000"
- },
- {
- "description": "Clothing - Slippers",
- "name": "Clothing - Slippers",
- "product_tax_code": "53111700A0000"
- },
- {
- "description": "Clothing - Boots",
- "name": "Clothing - Boots",
- "product_tax_code": "53111500A0000"
- },
- {
- "description": "Clothing - T-Shirts",
- "name": "Clothing - T-Shirts",
- "product_tax_code": "53103000A0000"
- },
- {
- "description": "Clothing - Swimwear",
- "name": "Clothing - Swimwear",
- "product_tax_code": "53102800A0000"
- },
- {
- "description": "Clothing - Coats or jackets",
- "name": "Clothing - Coats or jackets",
- "product_tax_code": "53101800A0000"
- },
- {
- "description": "Clothing - Prison officer uniform",
- "name": "Clothing - Prison officer uniform",
- "product_tax_code": "53102715A0000"
- },
- {
- "description": "Clothing - Corporate uniforms",
- "name": "Clothing - Corporate uniforms",
- "product_tax_code": "53102710A0000"
- },
- {
- "description": "Clothing - Security uniforms",
- "name": "Clothing - Security uniforms",
- "product_tax_code": "53102706A0000"
- },
- {
- "description": "Clothing - Chevrons",
- "name": "Clothing - Chevrons",
- "product_tax_code": "53102518A0000"
- },
- {
- "description": "Clothing - Disposable work coat",
- "name": "Clothing - Disposable work coat",
- "product_tax_code": "53103201A0000"
- },
- {
- "description": "Clothing - Bath robes",
- "name": "Clothing - Bath robes",
- "product_tax_code": "53102606A0000"
- },
- {
- "description": "Clothing - Bib",
- "name": "Clothing - Bib",
- "product_tax_code": "53102521A0000"
- },
- {
- "description": "Clothing - Gloves or mittens",
- "name": "Clothing - Gloves or mittens",
- "product_tax_code": "53102504A0000"
- },
- {
- "description": "Clothing - Mouth guards",
- "name": "Clothing - Mouth guards",
- "product_tax_code": "42152402A0001"
- },
- {
- "description": "Clothing - Boxing gloves",
- "name": "Clothing - Boxing gloves",
- "product_tax_code": "49171600A0000"
- },
- {
- "description": "Clothing - Golf shoes",
- "name": "Clothing - Golf shoes",
- "product_tax_code": "53111900A0004"
- },
- {
- "description": "Clothing - Bowling shoes",
- "name": "Clothing - Bowling shoes",
- "product_tax_code": "53111900A0003"
- },
- {
- "description": "Internet or intranet server application development services\r\n",
- "name": "Internet or intranet server application development services\r\n",
- "product_tax_code": "81111510A0000"
- },
- {
- "description": "Data conversion service\r\n",
- "name": "Data conversion service\r\n",
- "product_tax_code": "81112010A0000"
- },
- {
- "description": "Client or server programming services\r\n",
- "name": "Client or server programming services\r\n",
- "product_tax_code": "81111506A0000"
- },
- {
- "description": "Clothing - Ballet or tap shoes",
- "name": "Clothing - Ballet or tap shoes",
- "product_tax_code": "53111900A0001"
- },
- {
- "description": "Clothing - Golf gloves",
- "name": "Clothing - Golf gloves",
- "product_tax_code": "49211606A0000"
- },
- {
- "description": "Hardware as a service (HaaS)",
- "name": "Hardware as a service (HaaS)",
- "product_tax_code": "81161900A0000"
- },
- {
- "description": "Cloud-based platform as a service (PaaS) - Personal Use",
- "name": "Cloud-based platform as a service (PaaS) - Personal Use",
- "product_tax_code": "81162100A0000"
- },
- {
- "description": "Clothing - Panty hose",
- "name": "Clothing - Panty hose",
- "product_tax_code": "53102403A0000"
- },
- {
- "description": "Clothing - Brassieres",
- "name": "Clothing - Brassieres",
- "product_tax_code": "53102304A0000"
- },
- {
- "description": "Clothing - Protective sandals",
- "name": "Clothing - Protective sandals",
- "product_tax_code": "46181608A0000"
- },
- {
- "description": "Clothing - Tuxedo or Formalwear",
- "name": "Clothing - Tuxedo or Formalwear",
- "product_tax_code": "53101801A0001"
- },
- {
- "description": "Clothing - Lab coats",
- "name": "Clothing - Lab coats",
- "product_tax_code": "46181532A0000"
- },
- {
- "description": "Systems planning services\r\n",
- "name": "Systems planning services\r\n",
- "product_tax_code": "81111707A0000"
- },
- {
- "description": "Food and Beverage - Vitamins and Supplements",
- "name": "Food and Beverage - Vitamins and Supplements - labeled with supplement facts",
- "product_tax_code": "50501500A0000"
- },
- {
- "description": "Food and Beverage - Jello and pudding mixes",
- "name": "Food and Beverage - Jello and pudding mixes",
- "product_tax_code": "50192404A0000"
- },
- {
- "description": "Food and Beverage - Cooking spices",
- "name": "Food and Beverage - Cooking spices",
- "product_tax_code": "50171500A0000"
- },
- {
- "description": "Food and Beverage - Alcoholic beverages - Beer/Malt Beverages",
- "name": "Food and Beverage - Alcoholic beverages - Beer/Malt Beverages",
- "product_tax_code": "50202201A0000"
- },
- {
- "description": "Food and Beverage - Ice Cream, packaged",
- "name": "Food and Beverage - Ice Cream, packaged",
- "product_tax_code": "50192303A0000"
- },
- {
- "description": "Electronic content bundle - Delivered electronically with permanent rights of usage and streamed",
- "name": "Electronic content bundle - Delivered electronically with permanent rights of usage and streamed",
- "product_tax_code": "55111500A9210"
- },
- {
- "description": "Clothing - Roller skates or roller blades",
- "name": "Clothing - Roller skates or roller blades",
- "product_tax_code": "49221509A0000"
- },
- {
- "description": "Clothing - Ice Skates",
- "name": "Clothing - Ice Skates",
- "product_tax_code": "49151602A0000"
- },
- {
- "description": "Clothing - Life vests or preservers ",
- "name": "Clothing - Life vests or preservers ",
- "product_tax_code": "46161604A0000"
- },
- {
- "description": "Clothing - Swim goggles",
- "name": "Clothing - Swim goggles",
- "product_tax_code": "49141606A0000"
- },
- {
- "description": "Clothing - Bowling gloves",
- "name": "Clothing - Bowling gloves",
- "product_tax_code": "49211606A0002"
- },
- {
- "description": "Fire Extinguishers",
- "name": "Fire Extinguishers",
- "product_tax_code": "46191601A0000"
- },
- {
- "description": "Carbon Monoxide Detectors",
- "name": "Carbon Monoxide Detectors",
- "product_tax_code": "46191509A0001"
- },
- {
- "description": "Ladder used for home emergency evacuation.",
- "name": "Emergency/rescue ladder",
- "product_tax_code": "30191501A0001"
- },
- {
- "description": "Candles to be used a light source.",
- "name": "Candles",
- "product_tax_code": "39112604A0001"
- },
- {
- "description": "Non-electric water container to store water for emergency usage.",
- "name": "Water storage container",
- "product_tax_code": "24111810A0001"
- },
- {
- "description": "Duct Tape",
- "name": "Duct Tape",
- "product_tax_code": "31201501A0000"
- },
- {
- "description": "Gas-powered chainsaw.",
- "name": "Garden chainsaw",
- "product_tax_code": "27112038A0000"
- },
- {
- "description": "Chainsaw accessories include chains, lubricants, motor oil, chain sharpeners, bars, wrenches, carrying cases, repair parts, safety apparel.",
- "name": "Chainsaw accessories",
- "product_tax_code": "27112038A0001"
- },
- {
- "description": "Shower curtain/liner used to keep water from escaping a showering area.",
- "name": "Shower Curtain or Liner",
- "product_tax_code": "30181607A0000"
- },
- {
- "description": "Dish towels used for kitchenware drying.",
- "name": "Dish towels",
- "product_tax_code": "52121601A0000"
- },
- {
- "description": "A bumper/liner that borders the interior walls/slats of the crib to help protect the baby.",
- "name": "Crib bumpers/liners",
- "product_tax_code": "56101804A0001"
- },
- {
- "description": "A small mat/rug used to cover portion of bathroom floor.",
- "name": "Bath Mats/rugs",
- "product_tax_code": "52101507A0000"
- },
- {
- "description": "A handheld computer that is capable of plotting graphs, solving simultaneous equations, and performing other tasks with variables.",
- "name": "Graphing Calculators",
- "product_tax_code": "44101808A0001"
- },
- {
- "description": "Portable locks used by students in a school setting to prevent use, theft, vandalism or harm.",
- "name": "Padlocks - Student",
- "product_tax_code": "46171501A0001"
- },
- {
- "description": "Domestic clothes washing appliances carrying Energy Star rating.",
- "name": "Clothes Washing Machine - Energy Star",
- "product_tax_code": "52141601A0000"
- },
- {
- "description": "WaterSense labeled showerheads.",
- "name": "Showerheads - WaterSense",
- "product_tax_code": "30181801A0000"
- },
- {
- "description": "Domestic dish washing appliances carrying Energy Star rating.",
- "name": "Dishwashers - Energy Star",
- "product_tax_code": "52141505A0000"
- },
- {
- "description": "WaterSense labeled sprinkler body is the exterior shell that connects to the irrigation system piping and houses the spray nozzle that applies water on the landscape.",
- "name": "Spray Water Sprinkler Bodies - WaterSense",
- "product_tax_code": "21101803A0001"
- },
- {
- "description": "Ropes and Cords",
- "name": "Ropes and Cords",
- "product_tax_code": "31151500A0000"
- },
- {
- "description": "Light emitting diode (LED) bulbs carrying an Energy Star rating.",
- "name": "LED Bulbs - Energy Star",
- "product_tax_code": "39101628A0001"
- },
- {
- "description": "WaterSense labeled bathroom sink faucets and accessories.",
- "name": "Bathroom Faucets - WaterSense",
- "product_tax_code": "30181702A0001"
- },
- {
- "description": "Cables with industry standard connection and termination configurations used to connect various peripherals and equipment to computers.",
- "name": "Computer Cables",
- "product_tax_code": "43202222A0001"
- },
- {
- "description": "Canned software delivered electronically that is used for non-recreational purposes, such as Antivirus, Database, Educational, Financial, Word processing, etc.",
- "name": "Software - Prewritten, Electronic delivery - Non-recreational",
- "product_tax_code": "43230000A1102"
- },
- {
- "description": "Clothing - Baseball batting gloves",
- "name": "Clothing - Baseball batting gloves",
- "product_tax_code": "49211606A0001"
- },
- {
- "description": "Cloud-based platform as a service (PaaS) - Business Use",
- "name": "Cloud-based platform as a service (PaaS) - Business Use",
- "product_tax_code": "81162100A9000"
- },
- {
- "description": "Cloud-based Infrastructure as a service (IaaS) - Personal Use",
- "name": "Cloud-based infrastructure as a service (IaaS) - Personal Use",
- "product_tax_code": "81162200A0000"
- },
- {
- "description": "Clothing - Costume Mask",
- "name": "Clothing - Costume Mask",
- "product_tax_code": "60122800A0000"
- },
- {
- "description": "Clothing - Ski boots",
- "name": "Clothing - Ski boots",
- "product_tax_code": "53111900A0005"
- },
- {
- "description": "Personal computer PC application design\r\n",
- "name": "Personal computer PC application design\r\n",
- "product_tax_code": "81111502A0000"
- },
- {
- "description": "Network planning services\r\n",
- "name": "Network planning services\r\n",
- "product_tax_code": "81111706A0000"
- },
- {
- "description": "ERP or database applications programming services\r\n",
- "name": "ERP or database applications programming services\r\n",
- "product_tax_code": "81111507A0000"
- },
- {
- "description": "Content or data classification services\r\n",
- "name": "Content or data classification services\r\n",
- "product_tax_code": "81112009A0000"
- },
- {
- "description": "Clothing - Prom Dress",
- "name": "Clothing - Prom Dress",
- "product_tax_code": "53101801A0003"
- },
- {
- "description": "Clothing - Formal Dress",
- "name": "Clothing - Formal Dress",
- "product_tax_code": "53101801A0002"
- },
- {
- "description": "Clothing - Handkerchiefs",
- "name": "Clothing - Handkerchiefs",
- "product_tax_code": "53102512A0000"
- },
- {
- "description": "Clothing - Protective lens",
- "name": "Clothing - Protective lens",
- "product_tax_code": "46181811A0001"
- },
- {
- "description": "Clothing - Body shaping garments",
- "name": "Clothing - Body shaping garments",
- "product_tax_code": "53102307A0000"
- },
- {
- "description": "Clothing - Underpants",
- "name": "Clothing - Underpants",
- "product_tax_code": "53102303A0000"
- },
- {
- "description": "Clothing - Waterproof boot",
- "name": "Clothing - Waterproof boot",
- "product_tax_code": "46181611A0000"
- },
- {
- "description": "Electronic software documentation or user manuals - For prewritten software & delivered by load and leave",
- "name": "Electronic software documentation or user manuals - Prewritten, load and leave delivery",
- "product_tax_code": "55111601A1300"
- },
- {
- "description": "Electronic software documentation or user manuals - For custom software & delivered on tangible media",
- "name": "Electronic software documentation or user manuals - Custom, tangible media",
- "product_tax_code": "55111601A2100"
- },
- {
- "description": "Electronic software documentation or user manuals - For custom software & delivered by load and leave",
- "name": "Electronic software documentation or user manuals - Custom, load and leave delivery",
- "product_tax_code": "55111601A2300"
- },
- {
- "description": "Electronic software documentation or user manuals - For custom software & delivered electronically",
- "name": "Electronic software documentation or user manuals - Custom, electronic delivery",
- "product_tax_code": "55111601A2200"
- },
- {
- "description": "Electronic publications and music - Streamed",
- "name": "Electronic publications and music - Streamed",
- "product_tax_code": "55111500A1500"
- },
- {
- "description": "Electronic publications and music - Delivered electronically with permanent rights of usage",
- "name": "Electronic publications and music - Delivered electronically with permanent rights of usage",
- "product_tax_code": "55111500A1210"
- },
- {
- "description": "Electronic publications and music - Delivered electronically with less than permanent rights of usage",
- "name": "Electronic publications and music - Delivered electronically with less than permanent rights of usage",
- "product_tax_code": "55111500A1220"
- },
- {
- "description": "Software - Custom & delivered on tangible media",
- "name": "Software - Custom, tangible media",
- "product_tax_code": "43230000A2100"
- },
- {
- "description": "Software - Prewritten & delivered by digital keycode printed on tangible media",
- "name": "Software - Prewritten, delivered by digital keycode printed on tangible media",
- "product_tax_code": "43230000A1400"
- },
- {
- "description": "Software - Prewritten & delivered by load and leave",
- "name": "Software - Prewritten, load and leave delivery",
- "product_tax_code": "43230000A1300"
- },
- {
- "description": "Internet cloud storage service\r\n",
- "name": "Internet cloud storage service\r\n",
- "product_tax_code": "81111513A0000"
- },
- {
- "description": "Computer or network or internet security\r\n",
- "name": "Computer or network or internet security\r\n",
- "product_tax_code": "81111801A0000"
- },
- {
- "description": "Document scanning service\r\n",
- "name": "Document scanning service\r\n",
- "product_tax_code": "81112005A0000"
- },
- {
- "description": "Cloud-based software as a service (SaaS) - Business Use",
- "name": "Cloud-based software as a service (SaaS) - Business Use",
- "product_tax_code": "81162000A9000"
- },
- {
- "description": "Demining geographical or geospatial information system GIS\r\n",
- "name": "Demining geographical or geospatial information system GIS\r\n",
- "product_tax_code": "81111709A0000"
- },
- {
- "description": "Content or data standardization services\r\n",
- "name": "Content or data standardization services\r\n",
- "product_tax_code": "81112007A0000"
- },
- {
- "description": "Data processing or preparation services\r\n",
- "name": "Data processing or preparation services\r\n",
- "product_tax_code": "81112002A0000"
- },
- {
- "description": "Database analysis service\r\n",
- "name": "Database analysis service\r\n",
- "product_tax_code": "81111806A0000"
- },
- {
- "description": "Electronic software documentation or user manuals - For prewritten software & delivered on tangible media",
- "name": "Electronic software documentation or user manuals - Prewritten, tangible media",
- "product_tax_code": "55111601A1100"
- },
- {
- "description": "Cloud-based software as a service (SaaS) - Personal Use",
- "name": "Cloud-based software as a service (SaaS) - Personal Use",
- "product_tax_code": "81162000A0000"
- },
- {
- "description": "Clothing - Costume",
- "name": "Clothing - Costume",
- "product_tax_code": "60141401A0000"
- },
- {
- "description": "Online data processing service\r\n",
- "name": "Online data processing service\r\n",
- "product_tax_code": "81112001A0000"
- },
- {
- "description": "System usability services\r\n",
- "name": "System usability services\r\n",
- "product_tax_code": "81111820A0000"
- },
- {
- "description": "Services that provide both essential proactive and reactive operations and maintenance support.",
- "name": "IT Support Services",
- "product_tax_code": "81111811A0000"
- },
- {
- "description": "System analysis service\r\n",
- "name": "System analysis service\r\n",
- "product_tax_code": "81111808A0000"
- },
- {
- "description": "Data storage service\r\n",
- "name": "Data storage service\r\n",
- "product_tax_code": "81112006A0000"
- },
- {
- "description": "Quality assurance services\r\n",
- "name": "Quality assurance services\r\n",
- "product_tax_code": "81111819A0000"
- },
- {
- "description": "Software - Custom & delivered by load & leave",
- "name": "Software - Custom, load and leave delivery",
- "product_tax_code": "43230000A2300"
- },
- {
- "description": "Food and Beverage - Meat Sticks, Meat Jerky",
- "name": "Food and Beverage - Meat Sticks, Meat Jerky",
- "product_tax_code": "50112000A0000"
- },
- {
- "description": "Clothing - Synthetic Fur Poncho or Cape",
- "name": "Clothing - Synthetic Fur Poncho or Cape",
- "product_tax_code": "53101806A0002"
- },
- {
- "description": "Clothing - Wetsuit",
- "name": "Clothing - Wetsuit",
- "product_tax_code": "49141506A0000"
- },
- {
- "description": "Cloud-based business process as a service - Business Use",
- "name": "Cloud-based business process as a service - Business Use",
- "product_tax_code": "81162300A9000"
- },
- {
- "description": "Cloud-based infrastructure as a service (IaaS) - Business Use",
- "name": "Cloud-based infrastructure as a service (IaaS) - Business Use",
- "product_tax_code": "81162200A9000"
- },
- {
- "description": "Clothing - Belt Buckle",
- "name": "Clothing - Belt Buckle",
- "product_tax_code": "53102501A0001"
- },
- {
- "description": "Clothing - Shower Cap",
- "name": "Clothing - Shower Cap",
- "product_tax_code": "53131601A0000"
- },
- {
- "description": "Clothing - Eye shield garters",
- "name": "Clothing - Eye shield garters",
- "product_tax_code": "46181809A0001"
- },
- {
- "description": "Clothing - Socks",
- "name": "Clothing - Socks",
- "product_tax_code": "53102402A0000"
- },
- {
- "description": "Clothing - Stockings",
- "name": "Clothing - Stockings",
- "product_tax_code": "53102401A0000"
- },
- {
- "description": "Food and Beverage - Meat and meat products",
- "name": "Food and Beverage - Meat and meat products",
- "product_tax_code": "50110000A0000"
- },
- {
- "description": "Clothing - Slips",
- "name": "Clothing - Slips",
- "product_tax_code": "53102302A0000"
- },
- {
- "description": "Clothing - Goggle protective covers",
- "name": "Clothing - Goggle protective covers",
- "product_tax_code": "46181808A0001"
- },
- {
- "description": "Clothing - Goggles",
- "name": "Clothing - Goggles",
- "product_tax_code": "46181804A0001"
- },
- {
- "description": "Clothing - Football receiver gloves",
- "name": "Clothing - Football receiver gloves",
- "product_tax_code": "49211606A0004"
- },
- {
- "description": "Disaster recovery services",
- "name": "Disaster recovery services",
- "product_tax_code": "81112004A0000"
- },
- {
- "description": "Clothing - Mountain climbing boot",
- "name": "Clothing - Mountain climbing boot",
- "product_tax_code": "46181613A0000"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for prewritten software including items delivered on tangible media",
- "name": "Software maintenance and support - Mandatory, prewritten, tangible media",
- "product_tax_code": "81112200A1110"
- },
- {
- "description": "Clothing - Military boot",
- "name": "Clothing - Military boot",
- "product_tax_code": "46181612A0000"
- },
- {
- "description": "Carbonated beverages marketed as energy drinks, carrying a Supplement Facts Label, that contain a blend of energy enhancing vitamins, minerals, herbals, stimulants, etc.",
- "name": "Energy Beverages - Carbonated - with Supplement Facts Label",
- "product_tax_code": "50202309A0001"
- },
- {
- "description": "Non-carbonated beverages marketed as energy drinks, carrying a Supplement Facts Label, that contain a blend of energy enhancing vitamins, minerals, herbals, stimulants, etc.",
- "name": "Energy Beverages - Non-Carbonated - with Supplement Facts Label",
- "product_tax_code": "50202309A0000"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising 90% or more of the overall value of the bundle, where all food consists of candy (not containing flour).",
- "name": "Food/TPP Bundle - with Food 90% or more - Food is all Candy",
- "product_tax_code": "50193400A0001"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising less 90% or more of the overall value of the bundle.",
- "name": "Food/TPP Bundle - with Food 90% or more",
- "product_tax_code": "50193400A0000"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising between 76% and 89% of the overall value of the bundle, where all food consists of candy (not containing flour).",
- "name": "Food/TPP Bundle - with Food between 76% and 89% - Food is all Candy",
- "product_tax_code": "50193400A0005"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising between 76% and 89% of the overall value of the bundle.",
- "name": "Food/TPP Bundle - with Food between 76% and 89%",
- "product_tax_code": "50193400A0004"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising between 50% and 75% of the overall value of the bundle, where all food consists of candy (not containing flour).",
- "name": "Food/TPP Bundle - with Food between 50% and 75% - Food is all Candy",
- "product_tax_code": "50193400A0003"
- },
- {
- "description": "Food bundle or basket containing food staples combined with tangible personal property, with the food comprising between 50% and 75% of the overall value of the bundle.",
- "name": "Food/TPP Bundle - with Food between 50% and 75%",
- "product_tax_code": "50193400A0002"
- },
- {
- "description": "Food/TPP Bundle - with Food less than 50%",
- "name": "Food/TPP Bundle - with Food less than 50%",
- "product_tax_code": "50193400A0006"
- },
- {
- "description": "Ready to drink beverages, not containing milk, formulated and labled for their nutritional value, such as increased caloric or protein intake and containing natrual or artificial sweeteners.",
- "name": "Nutritional Supplement/protein drinks, shakes - contains no milk",
- "product_tax_code": "50501703A0000"
- },
- {
- "description": "Ready to drink beverages, containing milk, formulated and labled for their nutritional value, such as increased caloric or protein intake.",
- "name": "Nutritional Supplement/protein drinks, shakes - contains milk",
- "product_tax_code": "50501703A0001"
- },
- {
- "description": "Powdered mixes to be reconstituted into a drinkable beverage using water.",
- "name": "Powdered Drink Mixes - to be mixed with water",
- "product_tax_code": "50202311A0000"
- },
- {
- "description": "Powdered mixes to be reconstituted into a drinkable beverage using milk or a milk substitute.",
- "name": "Powdered Drink Mixes - to be mixed with milk",
- "product_tax_code": "50202311A0001"
- },
- {
- "description": "Food and Beverage - Granola Bars, Cereal Bars, Energy Bars, Protein Bars containing no flour",
- "name": "Food and Beverage - Granola Bars, Cereal Bars, Energy Bars, Protein Bars containing no flour",
- "product_tax_code": "50221202A0002"
- },
- {
- "description": "Food and Beverage - Granola Bars, Cereal Bars, Energy Bars, Protein Bars containing flour",
- "name": "Food and Beverage - Granola Bars, Cereal Bars, Energy Bars, Protein Bars containing flour",
- "product_tax_code": "50221202A0001"
- },
- {
- "description": "Nutritional supplement in powder form, dairy based or plant based, focused on increasing ones intake of protein for various benefits.",
- "name": "Protein Powder",
- "product_tax_code": "50501703A0002"
- },
- {
- "description": "Ready to drink non-carbonated beverage containing tea with natural or artificial sweeteners.",
- "name": "Bottled tea - non-carbonated - sweetened",
- "product_tax_code": "50201712A0003"
- },
- {
- "description": "Ready to drink non-carbonated beverage containing tea without natural or artificial sweeteners.",
- "name": "Bottled tea - non-carbonated - unsweetened",
- "product_tax_code": "50201712A0000"
- },
- {
- "description": "Ready to drink carbonated beverage containing tea with natural or artificial sweeteners.",
- "name": "Bottled tea - carbonated - sweetened",
- "product_tax_code": "50201712A0002"
- },
- {
- "description": "Ready to drink carbonated beverage containing tea and without any natural or artificial sweeteners.",
- "name": "Bottled tea - carbonated - unsweetened",
- "product_tax_code": "50201712A0001"
- },
- {
- "description": "Ready to drink coffee based beverage containing milk or milk substitute.",
- "name": "Bottled coffee - containing milk or milk substitute",
- "product_tax_code": "50201708A0002"
- },
- {
- "description": "Ready to drink coffee based beverage not containing milk, containing natural or artificial sweetener.",
- "name": "Bottled coffee - no milk - sweetened",
- "product_tax_code": "50201708A0001"
- },
- {
- "description": "Ready to drink coffee based beverage containing neither milk nor natural or artificial sweeteners.",
- "name": "Bottled coffee - no milk - unsweetened",
- "product_tax_code": "50201708A0000"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 51 - 69% natural vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 51-69% vegetable juice",
- "product_tax_code": "50202306A0008"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 1 - 9% natural fruit juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 1-9% fruit juice",
- "product_tax_code": "50202306A0001"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 70 - 99% natural fruit juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 70-99% fruit juice",
- "product_tax_code": "50202304A0010"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 51 - 69% natural vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 51-69% vegetable juice",
- "product_tax_code": "50202304A0009"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 25 - 50% natural vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 25-50% vegetable juice",
- "product_tax_code": "50202304A0007"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 10 - 24% natural fruit juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 10-24% fruit juice",
- "product_tax_code": "50202304A0004"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 70 - 99% natural vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 70-99% vegetable juice",
- "product_tax_code": "50202304A0011"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and zero natural fruit or vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - No fruit or vegetable juice",
- "product_tax_code": "50202304A0001"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 51 - 69% natural fruit juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 51-69% fruit juice",
- "product_tax_code": "50202304A0008"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 1 - 9% natural vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 1 -9% vegetable juice",
- "product_tax_code": "50202304A0003"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 1 - 9% natural fruit juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 1-9% fruit juice",
- "product_tax_code": "50202304A0002"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 10 - 24% natural vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 10-24% vegetable juice",
- "product_tax_code": "50202304A0005"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 25 - 50% natural fruit juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 25-50% fruit juice",
- "product_tax_code": "50202304A0006"
- },
- {
- "description": "Non-carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 100% natural fruit or vegetable juice. This does not include flavored water. This does include sweetened cocktail mixes that can be combined with alcohol. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Non-Carbonated - 100% fruit or vegetable juice",
- "product_tax_code": "50202304A0000"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and zero natural fruit or vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - No fruit or vegetable juice",
- "product_tax_code": "50202306A0000"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 70 - 99% natural vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 70-99% vegetable juice",
- "product_tax_code": "50202306A0010"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 70 - 99% natural fruit juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 70-99% fruit juice",
- "product_tax_code": "50202306A0009"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 51 - 69% natural fruit juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 51-69% fruit juice",
- "product_tax_code": "50202306A0007"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 25 - 50% natural vegetable juice. This does not flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 25-50% vegetable juice",
- "product_tax_code": "50202306A0006"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 25 - 50% natural fruit juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 25-50% fruit juice",
- "product_tax_code": "50202306A0005"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 100% natural fruit or vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 100% fruit or vegetable juice",
- "product_tax_code": "50202306A0011"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 10 - 24% natural vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 10-24% vegetable juice",
- "product_tax_code": "50202306A0004"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 10 - 24% natural fruit juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 10-24% fruit juice",
- "product_tax_code": "50202306A0003"
- },
- {
- "description": "Carbonated nonalcoholic beverages that contain natural or artificial sweeteners, and 1 - 9% natural vegetable juice. This does not include flavored carbonated water. This does include beverages marketed as energy drinks that carry a Nutrition Facts label and contain a blend of energy enhancing ingredients.",
- "name": "Soft Drinks - Carbonated - 1 -9% vegetable juice",
- "product_tax_code": "50202306A0002"
- },
- {
- "description": "Bottled Water for human consumption, unsweetened, non-carbonated. Does not include distilled water.",
- "name": "Bottled Water",
- "product_tax_code": "50202301A0000"
- },
- {
- "description": "Bottled Water for human consumption, containing natural or artificial sweeteners, non-carbonated.",
- "name": "Bottled Water - Flavored",
- "product_tax_code": "50202301A0001"
- },
- {
- "description": "Bottled Water for human consumption, unsweetened, carbonated naturally. Includes carbonated waters containing only natural flavors or essences.",
- "name": "Bottled Water - Carbonated Naturally",
- "product_tax_code": "50202301A0003"
- },
- {
- "description": "Bottled Water for human consumption, unsweetened, carbonated artificially during bottling process. Includes carbonated waters containing only natural flavors or essences.",
- "name": "Bottled Water - Carbonated Artificially",
- "product_tax_code": "50202301A0002"
- },
- {
- "description": "Bottled Water for human consumption, containing natural or artificial sweeteners, carbonated.",
- "name": "Bottled Water - Carbonated - Sweetened",
- "product_tax_code": "50202301A0004"
- },
- {
- "description": "Clothing - Sequins for use in clothing",
- "name": "Clothing - Sequins for use in clothing",
- "product_tax_code": "60123900A0000"
- },
- {
- "description": "Clothing - Synthetic Fur Gloves",
- "name": "Clothing - Synthetic Fur Gloves",
- "product_tax_code": "53102503A0002"
- },
- {
- "description": "Clothing - Synthetic Fur Coat or Jacket",
- "name": "Clothing - Synthetic Fur Coat or Jacket",
- "product_tax_code": "53101800A0002"
- },
- {
- "description": "Clothing - Fur Hat",
- "name": "Clothing - Fur Hat",
- "product_tax_code": "53102504A0001"
- },
- {
- "description": "Clothing - Fur Coat or Jacket",
- "name": "Clothing - Fur Coat or Jacket",
- "product_tax_code": "53101800A0001"
- },
- {
- "description": "Cloud-based business process as a service",
- "name": "Cloud-based business process as a service - Personal Use",
- "product_tax_code": "81162300A0000"
- },
- {
- "description": "Clothing - Shoulder pads for sports",
- "name": "Clothing - Shoulder pads for sports",
- "product_tax_code": "46181506A0002"
- },
- {
- "description": "Mainframe software applications design\r\n",
- "name": "Mainframe software applications design\r\n",
- "product_tax_code": "81111501A0000"
- },
- {
- "description": "Clothing - Safety shoes",
- "name": "Clothing - Safety shoes",
- "product_tax_code": "46181605A0000"
- },
- {
- "description": "Clothing - Protective hood",
- "name": "Clothing - Protective hood",
- "product_tax_code": "46181710A0001"
- },
- {
- "description": "Clothing - Face protection kit",
- "name": "Clothing - Face protection kit",
- "product_tax_code": "46181709A0001"
- },
- {
- "description": "Clothing - Protective hair net",
- "name": "Clothing - Protective hair net",
- "product_tax_code": "46181708A0001"
- },
- {
- "description": "Clothing - Facial shields parts or accessories",
- "name": "Clothing - Facial shields parts or accessories",
- "product_tax_code": "46181707A0001"
- },
- {
- "description": "Clothing - Safety helmets",
- "name": "Clothing - Safety helmets",
- "product_tax_code": "46181704A0001"
- },
- {
- "description": "Clothing - Poncho",
- "name": "Clothing - Poncho",
- "product_tax_code": "53101806A0000"
- },
- {
- "description": "Clothing - Protective insole",
- "name": "Clothing - Protective insole",
- "product_tax_code": "46181609A0000"
- },
- {
- "description": "Clothing - Protective clogs",
- "name": "Clothing - Protective clogs",
- "product_tax_code": "46181607A0000"
- },
- {
- "description": "Clothing - Waterproof jacket or raincoat",
- "name": "Clothing - Waterproof jacket or raincoat",
- "product_tax_code": "46181543A0000"
- },
- {
- "description": "Systems architecture\r\n",
- "name": "Systems architecture\r\n",
- "product_tax_code": "81111705A0000"
- },
- {
- "description": "System installation service\r\n",
- "name": "System installation service\r\n",
- "product_tax_code": "81111809A0000"
- },
- {
- "description": "Software maintenance and support - Mandatory maintenance and support charges for custom software including items delivered by load and leave",
- "name": "Software maintenance and support - Mandatory, custom, load and leave delivery",
- "product_tax_code": "81112200A2310"
- },
- {
- "description": "Software coding service\r\n",
- "name": "Software coding service\r\n",
- "product_tax_code": "81111810A0000"
- },
- {
- "description": "Software - Custom & delivered electronically",
- "name": "Software - Custom, electronic delivery",
- "product_tax_code": "43230000A2200"
- },
- {
- "description": "Bathing suits and swim suits",
- "name": "Clothing - Swimwear",
- "product_tax_code": "20041"
- },
- {
- "description": "Miscellaneous services which are not subject to a service-specific tax levy. This category will only treat services as taxable if the jurisdiction taxes services generally.",
- "name": "General Services",
- "product_tax_code": "19000"
- },
- {
- "description": "Services provided to educate users on the proper use of a product. Live training in person",
- "name": "Training Services - Live",
- "product_tax_code": "19004"
- },
- {
- "description": "Admission charges associated with entry to an event.",
- "name": "Admission Services",
- "product_tax_code": "19003"
- },
- {
- "description": "Service of providing usage of a parking space.",
- "name": "Parking Services",
- "product_tax_code": "19002"
- },
- {
- "description": "A charge separately stated from any sale of the product itself for the installation of tangible personal property. This a labor charge, with any non-separately stated property transferred in performing the service considered inconsequential.",
- "name": "Installation Services",
- "product_tax_code": "10040"
- },
- {
- "description": "Services rendered for advertising which do not include the exchange of tangible personal property.",
- "name": "Advertising Services",
- "product_tax_code": "19001"
- },
- {
- "description": "Digital products transferred electronically, meaning obtained by the purchaser by means other than tangible storage media.",
- "name": "Digital Goods",
- "product_tax_code": "31000"
- },
- {
- "description": " All human wearing apparel suitable for general use",
- "name": "Clothing",
- "product_tax_code": "20010"
- },
- {
- "description": "An over-the-counter drug is a substance that contains a label identifying it as a drug and including a \"drug facts\" panel or a statement of active ingredients, that can be obtained without a prescription. A drug can be intended for internal (ingestible, implant, injectable) or external (topical) application to the human body.",
- "name": "Over-the-Counter Drugs",
- "product_tax_code": "51010"
- },
- {
- "description": "A substance that can only be obtained via a prescription of a licensed professional. A drug is a compound, substance, or preparation, and any component thereof, not including food or food ingredients, dietary supplements, or alcoholic beverages, that is: recognized in the official United States pharmacopoeia, official homeopathic pharmacopoeia of the United States, or official national formulary, and supplement to any of them; intended for use in the diagnosis, cure, mitigation, treatment, or prevention of disease; or intended to affect the structure or any function of the body. A drug can be intended for internal (ingestible, implant, injectable) or external (topical) application to the human body.",
- "name": "Prescription Drugs",
- "product_tax_code": "51020"
- },
- {
- "description": "Food for humans consumption, unprepared",
- "name": "Food & Groceries",
- "product_tax_code": "40030"
- },
- {
- "description": "Pre-written software, delivered electronically, but access remotely.",
- "name": "Software as a Service",
- "product_tax_code": "30070"
- },
- {
- "description": "Periodicals, printed, sold by subscription",
- "name": "Magazines & Subscriptions",
- "product_tax_code": "81300"
- },
- {
- "description": "Books, printed",
- "name": "Books",
- "product_tax_code": "81100"
- },
- {
- "description": "Periodicals, printed, sold individually",
- "name": "Magazine",
- "product_tax_code": "81310"
- },
- {
- "description": "Textbooks, printed",
- "name": "Textbook",
- "product_tax_code": "81110"
- },
- {
- "description": "Religious books and manuals, printed",
- "name": "Religious books",
- "product_tax_code": "81120"
- },
- {
- "description": "Non-food dietary supplements",
- "name": "Supplements",
- "product_tax_code": "40020"
- },
- {
- "description": "Candy",
- "name": "Candy",
- "product_tax_code": "40010"
- },
- {
- "description": "Soft drinks. Soda and similar drinks. Does not include water, juice, or milk.",
- "name": "Soft Drinks",
- "product_tax_code": "40050"
- },
- {
- "description": "Bottled water for human consumption.",
- "name": "Bottled Water",
- "product_tax_code": "40060"
- },
- {
- "description": "Ready to eat foods intended to be consumed on site by humans. Foods not considered to be Food & Grocery (not food for home consumption or food which requires further preparation to consume).",
- "name": "Prepared Foods",
- "product_tax_code": "41000"
- }
- ]
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js
deleted file mode 100644
index 2925db82e3..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('TaxJar Settings', {
- is_sandbox: (frm) => {
- frm.toggle_reqd("api_key", !frm.doc.is_sandbox);
- frm.toggle_reqd("sandbox_api_key", frm.doc.is_sandbox);
- },
-
- on_load: (frm) => {
- frm.set_query('shipping_account_head', function() {
- return {
- filters: {
- 'company': frm.doc.company
- }
- };
- });
- frm.set_query('tax_account_head', function() {
- return {
- filters: {
- 'company': frm.doc.company
- }
- };
- });
- },
-
- refresh: (frm) => {
- frm.add_custom_button(__('Update Nexus List'), function() {
- frm.call({
- doc: frm.doc,
- method: 'update_nexus_list'
- });
- });
- },
-
-
-});
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json
deleted file mode 100644
index 6afd3f7038..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json
+++ /dev/null
@@ -1,139 +0,0 @@
-{
- "actions": [],
- "creation": "2017-06-15 08:21:24.624315",
- "doctype": "DocType",
- "document_type": "Setup",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "taxjar_calculate_tax",
- "is_sandbox",
- "taxjar_create_transactions",
- "credentials",
- "api_key",
- "cb_keys",
- "sandbox_api_key",
- "configuration",
- "company",
- "column_break_10",
- "tax_account_head",
- "configuration_cb",
- "shipping_account_head",
- "section_break_12",
- "nexus"
- ],
- "fields": [
- {
- "fieldname": "credentials",
- "fieldtype": "Section Break",
- "label": "Credentials"
- },
- {
- "fieldname": "api_key",
- "fieldtype": "Password",
- "in_list_view": 1,
- "label": "Live API Key",
- "reqd": 1
- },
- {
- "fieldname": "configuration",
- "fieldtype": "Section Break",
- "label": "Configuration"
- },
- {
- "fieldname": "tax_account_head",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Tax Account Head",
- "options": "Account",
- "reqd": 1
- },
- {
- "fieldname": "shipping_account_head",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Shipping Account Head",
- "options": "Account",
- "reqd": 1
- },
- {
- "default": "0",
- "depends_on": "taxjar_calculate_tax",
- "fieldname": "is_sandbox",
- "fieldtype": "Check",
- "label": "Sandbox Mode"
- },
- {
- "fieldname": "sandbox_api_key",
- "fieldtype": "Password",
- "label": "Sandbox API Key"
- },
- {
- "default": "0",
- "depends_on": "taxjar_calculate_tax",
- "fieldname": "taxjar_create_transactions",
- "fieldtype": "Check",
- "label": "Create TaxJar Transaction"
- },
- {
- "default": "0",
- "fieldname": "taxjar_calculate_tax",
- "fieldtype": "Check",
- "label": "Enable Tax Calculation"
- },
- {
- "fieldname": "cb_keys",
- "fieldtype": "Column Break"
- },
- {
- "depends_on": "nexus",
- "fieldname": "section_break_12",
- "fieldtype": "Section Break",
- "label": "Nexus List"
- },
- {
- "fieldname": "nexus",
- "fieldtype": "Table",
- "label": "Nexus",
- "options": "TaxJar Nexus",
- "read_only": 1
- },
- {
- "fieldname": "configuration_cb",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "company",
- "fieldtype": "Link",
- "label": "Company",
- "options": "Company"
- },
- {
- "fieldname": "column_break_10",
- "fieldtype": "Column Break"
- }
- ],
- "issingle": 1,
- "links": [],
- "modified": "2021-11-30 12:17:24.647979",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "TaxJar Settings",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py
deleted file mode 100644
index 2148863c55..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
-
-
-import json
-import os
-
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-from frappe.model.document import Document
-from frappe.permissions import add_permission, update_permission_property
-
-from erpnext.erpnext_integrations.taxjar_integration import get_client
-
-
-class TaxJarSettings(Document):
- def on_update(self):
- TAXJAR_CREATE_TRANSACTIONS = self.taxjar_create_transactions
- TAXJAR_CALCULATE_TAX = self.taxjar_calculate_tax
- TAXJAR_SANDBOX_MODE = self.is_sandbox
-
- fields_already_exist = frappe.db.exists(
- "Custom Field",
- {"dt": ("in", ["Item", "Sales Invoice Item"]), "fieldname": "product_tax_category"},
- )
- fields_hidden = frappe.get_value(
- "Custom Field", {"dt": ("in", ["Sales Invoice Item"])}, "hidden"
- )
-
- if TAXJAR_CREATE_TRANSACTIONS or TAXJAR_CALCULATE_TAX or TAXJAR_SANDBOX_MODE:
- if not fields_already_exist:
- add_product_tax_categories()
- make_custom_fields()
- add_permissions()
- frappe.enqueue("erpnext.regional.united_states.setup.add_product_tax_categories", now=False)
-
- elif fields_already_exist and fields_hidden:
- toggle_tax_category_fields(hidden="0")
-
- elif fields_already_exist:
- toggle_tax_category_fields(hidden="1")
-
- def validate(self):
- self.calculate_taxes_validation_for_create_transactions()
-
- @frappe.whitelist()
- def update_nexus_list(self):
- client = get_client()
- nexus = client.nexus_regions()
-
- new_nexus_list = [frappe._dict(address) for address in nexus]
-
- self.set("nexus", [])
- self.set("nexus", new_nexus_list)
- self.save()
-
- def calculate_taxes_validation_for_create_transactions(self):
- if not self.taxjar_calculate_tax and (self.taxjar_create_transactions or self.is_sandbox):
- frappe.throw(
- frappe._(
- "Before enabling Create Transaction or Sandbox Mode, you need to check the Enable Tax Calculation box"
- )
- )
-
-
-def toggle_tax_category_fields(hidden):
- frappe.set_value(
- "Custom Field",
- {"dt": "Sales Invoice Item", "fieldname": "product_tax_category"},
- "hidden",
- hidden,
- )
- frappe.set_value(
- "Custom Field", {"dt": "Item", "fieldname": "product_tax_category"}, "hidden", hidden
- )
-
-
-def add_product_tax_categories():
- with open(os.path.join(os.path.dirname(__file__), "product_tax_category_data.json"), "r") as f:
- tax_categories = json.loads(f.read())
- create_tax_categories(tax_categories["categories"])
-
-
-def create_tax_categories(data):
- for d in data:
- if not frappe.db.exists("Product Tax Category", {"product_tax_code": d.get("product_tax_code")}):
- tax_category = frappe.new_doc("Product Tax Category")
- tax_category.description = d.get("description")
- tax_category.product_tax_code = d.get("product_tax_code")
- tax_category.category_name = d.get("name")
- tax_category.db_insert()
-
-
-def make_custom_fields(update=True):
- custom_fields = {
- "Sales Invoice Item": [
- dict(
- fieldname="product_tax_category",
- fieldtype="Link",
- insert_after="description",
- options="Product Tax Category",
- label="Product Tax Category",
- fetch_from="item_code.product_tax_category",
- ),
- dict(
- fieldname="tax_collectable",
- fieldtype="Currency",
- insert_after="net_amount",
- label="Tax Collectable",
- read_only=1,
- options="currency",
- ),
- dict(
- fieldname="taxable_amount",
- fieldtype="Currency",
- insert_after="tax_collectable",
- label="Taxable Amount",
- read_only=1,
- options="currency",
- ),
- ],
- "Item": [
- dict(
- fieldname="product_tax_category",
- fieldtype="Link",
- insert_after="item_group",
- options="Product Tax Category",
- label="Product Tax Category",
- )
- ],
- }
- create_custom_fields(custom_fields, update=update)
-
-
-def add_permissions():
- doctype = "Product Tax Category"
- for role in (
- "Accounts Manager",
- "Accounts User",
- "System Manager",
- "Item Manager",
- "Stock Manager",
- ):
- add_permission(doctype, role, 0)
- update_permission_property(doctype, role, 0, "write", 1)
- update_permission_property(doctype, role, 0, "create", 1)
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/test_taxjar_settings.py b/erpnext/erpnext_integrations/doctype/taxjar_settings/test_taxjar_settings.py
deleted file mode 100644
index d6f8eea1e0..0000000000
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/test_taxjar_settings.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-# import frappe
-import unittest
-
-
-class TestTaxJarSettings(unittest.TestCase):
- pass
diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py
index 2e18776a92..4aa98aab56 100644
--- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py
+++ b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py
@@ -6,7 +6,7 @@ from urllib.parse import urlparse
import frappe
from frappe import _
-from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
from frappe.utils.nestedset import get_root_of
@@ -19,27 +19,24 @@ class WoocommerceSettings(Document):
def create_delete_custom_fields(self):
if self.enable_sync:
- custom_fields = {}
- # create
- for doctype in ["Customer", "Sales Order", "Item", "Address"]:
- df = dict(
- fieldname="woocommerce_id",
- label="Woocommerce ID",
- fieldtype="Data",
- read_only=1,
- print_hide=1,
- )
- create_custom_field(doctype, df)
-
- for doctype in ["Customer", "Address"]:
- df = dict(
- fieldname="woocommerce_email",
- label="Woocommerce Email",
- fieldtype="Data",
- read_only=1,
- print_hide=1,
- )
- create_custom_field(doctype, df)
+ create_custom_fields(
+ {
+ ("Customer", "Sales Order", "Item", "Address"): dict(
+ fieldname="woocommerce_id",
+ label="Woocommerce ID",
+ fieldtype="Data",
+ read_only=1,
+ print_hide=1,
+ ),
+ ("Customer", "Address"): dict(
+ fieldname="woocommerce_email",
+ label="Woocommerce Email",
+ fieldtype="Data",
+ read_only=1,
+ print_hide=1,
+ ),
+ }
+ )
if not frappe.get_value("Item Group", {"name": _("WooCommerce Products")}):
item_group = frappe.new_doc("Item Group")
diff --git a/erpnext/erpnext_integrations/stripe_integration.py b/erpnext/erpnext_integrations/stripe_integration.py
index 2d7e8a5d31..634e5c2e89 100644
--- a/erpnext/erpnext_integrations/stripe_integration.py
+++ b/erpnext/erpnext_integrations/stripe_integration.py
@@ -2,12 +2,16 @@
# For license information, please see license.txt
import frappe
-import stripe
from frappe import _
from frappe.integrations.utils import create_request_log
+from erpnext.utilities import payment_app_import_guard
+
def create_stripe_subscription(gateway_controller, data):
+ with payment_app_import_guard():
+ import stripe
+
stripe_settings = frappe.get_doc("Stripe Settings", gateway_controller)
stripe_settings.data = frappe._dict(data)
@@ -35,6 +39,9 @@ def create_stripe_subscription(gateway_controller, data):
def create_subscription_on_stripe(stripe_settings):
+ with payment_app_import_guard():
+ import stripe
+
items = []
for payment_plan in stripe_settings.payment_plans:
plan = frappe.db.get_value("Subscription Plan", payment_plan.plan, "product_price_id")
diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py
deleted file mode 100644
index b8893aa773..0000000000
--- a/erpnext/erpnext_integrations/taxjar_integration.py
+++ /dev/null
@@ -1,419 +0,0 @@
-import traceback
-
-import frappe
-import taxjar
-from frappe import _
-from frappe.contacts.doctype.address.address import get_company_address
-from frappe.utils import cint, flt
-
-from erpnext import get_default_company, get_region
-
-SUPPORTED_COUNTRY_CODES = [
- "AT",
- "AU",
- "BE",
- "BG",
- "CA",
- "CY",
- "CZ",
- "DE",
- "DK",
- "EE",
- "ES",
- "FI",
- "FR",
- "GB",
- "GR",
- "HR",
- "HU",
- "IE",
- "IT",
- "LT",
- "LU",
- "LV",
- "MT",
- "NL",
- "PL",
- "PT",
- "RO",
- "SE",
- "SI",
- "SK",
- "US",
-]
-SUPPORTED_STATE_CODES = [
- "AL",
- "AK",
- "AZ",
- "AR",
- "CA",
- "CO",
- "CT",
- "DE",
- "DC",
- "FL",
- "GA",
- "HI",
- "ID",
- "IL",
- "IN",
- "IA",
- "KS",
- "KY",
- "LA",
- "ME",
- "MD",
- "MA",
- "MI",
- "MN",
- "MS",
- "MO",
- "MT",
- "NE",
- "NV",
- "NH",
- "NJ",
- "NM",
- "NY",
- "NC",
- "ND",
- "OH",
- "OK",
- "OR",
- "PA",
- "RI",
- "SC",
- "SD",
- "TN",
- "TX",
- "UT",
- "VT",
- "VA",
- "WA",
- "WV",
- "WI",
- "WY",
-]
-
-
-def get_client():
- taxjar_settings = frappe.get_single("TaxJar Settings")
-
- if not taxjar_settings.is_sandbox:
- api_key = taxjar_settings.api_key and taxjar_settings.get_password("api_key")
- api_url = taxjar.DEFAULT_API_URL
- else:
- api_key = taxjar_settings.sandbox_api_key and taxjar_settings.get_password("sandbox_api_key")
- api_url = taxjar.SANDBOX_API_URL
-
- if api_key and api_url:
- client = taxjar.Client(api_key=api_key, api_url=api_url)
- client.set_api_config("headers", {"x-api-version": "2022-01-24"})
- return client
-
-
-def create_transaction(doc, method):
- TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value(
- "TaxJar Settings", "taxjar_create_transactions"
- )
-
- """Create an order transaction in TaxJar"""
-
- if not TAXJAR_CREATE_TRANSACTIONS:
- return
-
- client = get_client()
-
- if not client:
- return
-
- TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
- sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD])
-
- if not sales_tax:
- return
-
- tax_dict = get_tax_data(doc)
-
- if not tax_dict:
- return
-
- tax_dict["transaction_id"] = doc.name
- tax_dict["transaction_date"] = frappe.utils.today()
- tax_dict["sales_tax"] = sales_tax
- tax_dict["amount"] = doc.total + tax_dict["shipping"]
-
- try:
- if doc.is_return:
- client.create_refund(tax_dict)
- else:
- client.create_order(tax_dict)
- except taxjar.exceptions.TaxJarResponseError as err:
- frappe.throw(_(sanitize_error_response(err)))
- except Exception as ex:
- print(traceback.format_exc(ex))
-
-
-def delete_transaction(doc, method):
- """Delete an existing TaxJar order transaction"""
- TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value(
- "TaxJar Settings", "taxjar_create_transactions"
- )
-
- if not TAXJAR_CREATE_TRANSACTIONS:
- return
-
- client = get_client()
-
- if not client:
- return
-
- client.delete_order(doc.name)
-
-
-def get_tax_data(doc):
- SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head")
-
- from_address = get_company_address_details(doc)
- from_shipping_state = from_address.get("state")
- from_country_code = frappe.db.get_value("Country", from_address.country, "code")
- from_country_code = from_country_code.upper()
-
- to_address = get_shipping_address_details(doc)
- to_shipping_state = to_address.get("state")
- to_country_code = frappe.db.get_value("Country", to_address.country, "code")
- to_country_code = to_country_code.upper()
-
- shipping = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == SHIP_ACCOUNT_HEAD])
-
- line_items = [get_line_item_dict(item, doc.docstatus) for item in doc.items]
-
- if from_shipping_state not in SUPPORTED_STATE_CODES:
- from_shipping_state = get_state_code(from_address, "Company")
-
- if to_shipping_state not in SUPPORTED_STATE_CODES:
- to_shipping_state = get_state_code(to_address, "Shipping")
-
- tax_dict = {
- "from_country": from_country_code,
- "from_zip": from_address.pincode,
- "from_state": from_shipping_state,
- "from_city": from_address.city,
- "from_street": from_address.address_line1,
- "to_country": to_country_code,
- "to_zip": to_address.pincode,
- "to_city": to_address.city,
- "to_street": to_address.address_line1,
- "to_state": to_shipping_state,
- "shipping": shipping,
- "amount": doc.net_total,
- "plugin": "erpnext",
- "line_items": line_items,
- }
- return tax_dict
-
-
-def get_state_code(address, location):
- if address is not None:
- state_code = get_iso_3166_2_state_code(address)
- if state_code not in SUPPORTED_STATE_CODES:
- frappe.throw(_("Please enter a valid State in the {0} Address").format(location))
- else:
- frappe.throw(_("Please enter a valid State in the {0} Address").format(location))
-
- return state_code
-
-
-def get_line_item_dict(item, docstatus):
- tax_dict = dict(
- id=item.get("idx"),
- quantity=item.get("qty"),
- unit_price=item.get("rate"),
- product_tax_code=item.get("product_tax_category"),
- )
-
- if docstatus == 1:
- tax_dict.update({"sales_tax": item.get("tax_collectable")})
-
- return tax_dict
-
-
-def set_sales_tax(doc, method):
- TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
- TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
-
- if not TAXJAR_CALCULATE_TAX:
- return
-
- if get_region(doc.company) != "United States":
- return
-
- if not doc.items:
- return
-
- if check_sales_tax_exemption(doc):
- return
-
- tax_dict = get_tax_data(doc)
-
- if not tax_dict:
- # Remove existing tax rows if address is changed from a taxable state/country
- setattr(doc, "taxes", [tax for tax in doc.taxes if tax.account_head != TAX_ACCOUNT_HEAD])
- return
-
- # check if delivering within a nexus
- check_for_nexus(doc, tax_dict)
-
- tax_data = validate_tax_request(tax_dict)
- if tax_data is not None:
- if not tax_data.amount_to_collect:
- setattr(doc, "taxes", [tax for tax in doc.taxes if tax.account_head != TAX_ACCOUNT_HEAD])
- elif tax_data.amount_to_collect > 0:
- # Loop through tax rows for existing Sales Tax entry
- # If none are found, add a row with the tax amount
- for tax in doc.taxes:
- if tax.account_head == TAX_ACCOUNT_HEAD:
- tax.tax_amount = tax_data.amount_to_collect
-
- doc.run_method("calculate_taxes_and_totals")
- break
- else:
- doc.append(
- "taxes",
- {
- "charge_type": "Actual",
- "description": "Sales Tax",
- "account_head": TAX_ACCOUNT_HEAD,
- "tax_amount": tax_data.amount_to_collect,
- },
- )
- # Assigning values to tax_collectable and taxable_amount fields in sales item table
- for item in tax_data.breakdown.line_items:
- doc.get("items")[cint(item.id) - 1].tax_collectable = item.tax_collectable
- doc.get("items")[cint(item.id) - 1].taxable_amount = item.taxable_amount
-
- doc.run_method("calculate_taxes_and_totals")
-
-
-def check_for_nexus(doc, tax_dict):
- TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
- if not frappe.db.get_value("TaxJar Nexus", {"region_code": tax_dict["to_state"]}):
- for item in doc.get("items"):
- item.tax_collectable = flt(0)
- item.taxable_amount = flt(0)
-
- for tax in doc.taxes:
- if tax.account_head == TAX_ACCOUNT_HEAD:
- doc.taxes.remove(tax)
- return
-
-
-def check_sales_tax_exemption(doc):
- # if the party is exempt from sales tax, then set all tax account heads to zero
- TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
-
- sales_tax_exempted = (
- hasattr(doc, "exempt_from_sales_tax")
- and doc.exempt_from_sales_tax
- or frappe.db.has_column("Customer", "exempt_from_sales_tax")
- and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax")
- )
-
- if sales_tax_exempted:
- for tax in doc.taxes:
- if tax.account_head == TAX_ACCOUNT_HEAD:
- tax.tax_amount = 0
- break
- doc.run_method("calculate_taxes_and_totals")
- return True
- else:
- return False
-
-
-def validate_tax_request(tax_dict):
- """Return the sales tax that should be collected for a given order."""
-
- client = get_client()
-
- if not client:
- return
-
- try:
- tax_data = client.tax_for_order(tax_dict)
- except taxjar.exceptions.TaxJarResponseError as err:
- frappe.throw(_(sanitize_error_response(err)))
- else:
- return tax_data
-
-
-def get_company_address_details(doc):
- """Return default company address details"""
-
- company_address = get_company_address(get_default_company()).company_address
-
- if not company_address:
- frappe.throw(_("Please set a default company address"))
-
- company_address = frappe.get_doc("Address", company_address)
- return company_address
-
-
-def get_shipping_address_details(doc):
- """Return customer shipping address details"""
-
- if doc.shipping_address_name:
- shipping_address = frappe.get_doc("Address", doc.shipping_address_name)
- elif doc.customer_address:
- shipping_address = frappe.get_doc("Address", doc.customer_address)
- else:
- shipping_address = get_company_address_details(doc)
-
- return shipping_address
-
-
-def get_iso_3166_2_state_code(address):
- import pycountry
-
- country_code = frappe.db.get_value("Country", address.get("country"), "code")
-
- error_message = _(
- """{0} is not a valid state! Check for typos or enter the ISO code for your state."""
- ).format(address.get("state"))
- state = address.get("state").upper().strip()
-
- # The max length for ISO state codes is 3, excluding the country code
- if len(state) <= 3:
- # PyCountry returns state code as {country_code}-{state-code} (e.g. US-FL)
- address_state = (country_code + "-" + state).upper()
-
- states = pycountry.subdivisions.get(country_code=country_code.upper())
- states = [pystate.code for pystate in states]
-
- if address_state in states:
- return state
-
- frappe.throw(_(error_message))
- else:
- try:
- lookup_state = pycountry.subdivisions.lookup(state)
- except LookupError:
- frappe.throw(_(error_message))
- else:
- return lookup_state.code.split("-")[1]
-
-
-def sanitize_error_response(response):
- response = response.full_response.get("detail")
- response = response.replace("_", " ")
-
- sanitized_responses = {
- "to zip": "Zipcode",
- "to city": "City",
- "to state": "State",
- "to country": "Country",
- }
-
- for k, v in sanitized_responses.items():
- response = response.replace(k, v)
-
- return response
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index aa10e31744..fba886ca2c 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -1,5 +1,3 @@
-from frappe import _
-
app_name = "erpnext"
app_title = "ERPNext"
app_publisher = "Frappe Technologies Pvt. Ltd."
@@ -10,7 +8,6 @@ app_email = "info@erpnext.com"
app_license = "GNU General Public License (v3)"
source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
-required_apps = ["payments"]
develop_version = "14.x.x-develop"
@@ -94,7 +91,7 @@ website_route_rules = [
{
"from_route": "/orders/ ",
"to_route": "order",
- "defaults": {"doctype": "Sales Order", "parents": [{"label": _("Orders"), "route": "orders"}]},
+ "defaults": {"doctype": "Sales Order", "parents": [{"label": "Orders", "route": "orders"}]},
},
{"from_route": "/invoices", "to_route": "Sales Invoice"},
{
@@ -102,7 +99,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Sales Invoice",
- "parents": [{"label": _("Invoices"), "route": "invoices"}],
+ "parents": [{"label": "Invoices", "route": "invoices"}],
},
},
{"from_route": "/supplier-quotations", "to_route": "Supplier Quotation"},
@@ -111,7 +108,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Supplier Quotation",
- "parents": [{"label": _("Supplier Quotation"), "route": "supplier-quotations"}],
+ "parents": [{"label": "Supplier Quotation", "route": "supplier-quotations"}],
},
},
{"from_route": "/purchase-orders", "to_route": "Purchase Order"},
@@ -120,7 +117,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Purchase Order",
- "parents": [{"label": _("Purchase Order"), "route": "purchase-orders"}],
+ "parents": [{"label": "Purchase Order", "route": "purchase-orders"}],
},
},
{"from_route": "/purchase-invoices", "to_route": "Purchase Invoice"},
@@ -129,7 +126,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Purchase Invoice",
- "parents": [{"label": _("Purchase Invoice"), "route": "purchase-invoices"}],
+ "parents": [{"label": "Purchase Invoice", "route": "purchase-invoices"}],
},
},
{"from_route": "/quotations", "to_route": "Quotation"},
@@ -138,7 +135,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Quotation",
- "parents": [{"label": _("Quotations"), "route": "quotations"}],
+ "parents": [{"label": "Quotations", "route": "quotations"}],
},
},
{"from_route": "/shipments", "to_route": "Delivery Note"},
@@ -147,7 +144,7 @@ website_route_rules = [
"to_route": "order",
"defaults": {
"doctype": "Delivery Note",
- "parents": [{"label": _("Shipments"), "route": "shipments"}],
+ "parents": [{"label": "Shipments", "route": "shipments"}],
},
},
{"from_route": "/rfq", "to_route": "Request for Quotation"},
@@ -156,14 +153,14 @@ website_route_rules = [
"to_route": "rfq",
"defaults": {
"doctype": "Request for Quotation",
- "parents": [{"label": _("Request for Quotation"), "route": "rfq"}],
+ "parents": [{"label": "Request for Quotation", "route": "rfq"}],
},
},
{"from_route": "/addresses", "to_route": "Address"},
{
"from_route": "/addresses/",
"to_route": "addresses",
- "defaults": {"doctype": "Address", "parents": [{"label": _("Addresses"), "route": "addresses"}]},
+ "defaults": {"doctype": "Address", "parents": [{"label": "Addresses", "route": "addresses"}]},
},
{"from_route": "/boms", "to_route": "BOM"},
{"from_route": "/timesheets", "to_route": "Timesheet"},
@@ -173,78 +170,79 @@ website_route_rules = [
"to_route": "material_request_info",
"defaults": {
"doctype": "Material Request",
- "parents": [{"label": _("Material Request"), "route": "material-requests"}],
+ "parents": [{"label": "Material Request", "route": "material-requests"}],
},
},
{"from_route": "/project", "to_route": "Project"},
+ {"from_route": "/tasks", "to_route": "Task"},
]
standard_portal_menu_items = [
- {"title": _("Projects"), "route": "/project", "reference_doctype": "Project"},
+ {"title": "Projects", "route": "/project", "reference_doctype": "Project"},
{
- "title": _("Request for Quotations"),
+ "title": "Request for Quotations",
"route": "/rfq",
"reference_doctype": "Request for Quotation",
"role": "Supplier",
},
{
- "title": _("Supplier Quotation"),
+ "title": "Supplier Quotation",
"route": "/supplier-quotations",
"reference_doctype": "Supplier Quotation",
"role": "Supplier",
},
{
- "title": _("Purchase Orders"),
+ "title": "Purchase Orders",
"route": "/purchase-orders",
"reference_doctype": "Purchase Order",
"role": "Supplier",
},
{
- "title": _("Purchase Invoices"),
+ "title": "Purchase Invoices",
"route": "/purchase-invoices",
"reference_doctype": "Purchase Invoice",
"role": "Supplier",
},
{
- "title": _("Quotations"),
+ "title": "Quotations",
"route": "/quotations",
"reference_doctype": "Quotation",
"role": "Customer",
},
{
- "title": _("Orders"),
+ "title": "Orders",
"route": "/orders",
"reference_doctype": "Sales Order",
"role": "Customer",
},
{
- "title": _("Invoices"),
+ "title": "Invoices",
"route": "/invoices",
"reference_doctype": "Sales Invoice",
"role": "Customer",
},
{
- "title": _("Shipments"),
+ "title": "Shipments",
"route": "/shipments",
"reference_doctype": "Delivery Note",
"role": "Customer",
},
- {"title": _("Issues"), "route": "/issues", "reference_doctype": "Issue", "role": "Customer"},
- {"title": _("Addresses"), "route": "/addresses", "reference_doctype": "Address"},
+ {"title": "Issues", "route": "/issues", "reference_doctype": "Issue", "role": "Customer"},
+ {"title": "Addresses", "route": "/addresses", "reference_doctype": "Address"},
{
- "title": _("Timesheets"),
+ "title": "Timesheets",
"route": "/timesheets",
"reference_doctype": "Timesheet",
"role": "Customer",
},
- {"title": _("Newsletter"), "route": "/newsletters", "reference_doctype": "Newsletter"},
+ {"title": "Newsletter", "route": "/newsletters", "reference_doctype": "Newsletter"},
{
- "title": _("Material Request"),
+ "title": "Material Request",
"route": "/material-requests",
"reference_doctype": "Material Request",
"role": "Customer",
},
- {"title": _("Appointment Booking"), "route": "/book_appointment"},
+ {"title": "Appointment Booking", "route": "/book_appointment"},
]
default_roles = [
@@ -274,8 +272,6 @@ has_website_permission = {
"Timesheet": "erpnext.controllers.website_list_for_contact.has_website_permission",
}
-dump_report_map = "erpnext.startup.report_data_map.data_map"
-
before_tests = "erpnext.setup.utils.before_tests"
standard_queries = {
@@ -315,17 +311,10 @@ doc_events = {
"on_submit": [
"erpnext.regional.create_transaction_log",
"erpnext.regional.italy.utils.sales_invoice_on_submit",
- "erpnext.regional.saudi_arabia.utils.create_qr_code",
- "erpnext.erpnext_integrations.taxjar_integration.create_transaction",
- ],
- "on_cancel": [
- "erpnext.regional.italy.utils.sales_invoice_on_cancel",
- "erpnext.erpnext_integrations.taxjar_integration.delete_transaction",
- "erpnext.regional.saudi_arabia.utils.delete_qr_code_file",
],
+ "on_cancel": ["erpnext.regional.italy.utils.sales_invoice_on_cancel"],
"on_trash": "erpnext.regional.check_deletion_permission",
},
- "POS Invoice": {"on_submit": ["erpnext.regional.saudi_arabia.utils.create_qr_code"]},
"Purchase Invoice": {
"validate": [
"erpnext.regional.united_arab_emirates.utils.update_grand_total_for_rcm",
@@ -353,10 +342,6 @@ doc_events = {
"Email Unsubscribe": {
"after_insert": "erpnext.crm.doctype.email_campaign.email_campaign.unsubscribe_recipient"
},
- ("Quotation", "Sales Order", "Sales Invoice"): {
- "validate": ["erpnext.erpnext_integrations.taxjar_integration.set_sales_tax"]
- },
- "Company": {"on_trash": ["erpnext.regional.saudi_arabia.utils.delete_vat_settings_for_company"]},
"Integration Request": {
"validate": "erpnext.accounts.doctype.payment_request.payment_request.validate_payment"
},
@@ -391,12 +376,12 @@ scheduler_events = {
"erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts",
],
"hourly": [
- "erpnext.accounts.doctype.subscription.subscription.process_all",
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.hourly_reminder",
"erpnext.projects.doctype.project.project.collect_project_status",
],
"hourly_long": [
+ "erpnext.accounts.doctype.subscription.subscription.process_all",
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
"erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction",
],
@@ -507,6 +492,8 @@ accounting_dimension_doctypes = [
"Shipping Rule",
"Landed Cost Item",
"Asset Value Adjustment",
+ "Asset Repair",
+ "Asset Capitalization",
"Loyalty Program",
"Stock Reconciliation",
"POS Profile",
@@ -519,6 +506,10 @@ accounting_dimension_doctypes = [
"Purchase Order",
"Purchase Receipt",
"Sales Order",
+ "Subcontracting Order",
+ "Subcontracting Order Item",
+ "Subcontracting Receipt",
+ "Subcontracting Receipt Item",
]
# get matching queries for Bank Reconciliation
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index 38328e6967..20e2b0b201 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -61,6 +61,10 @@ frappe.ui.form.on('Loan', {
},
refresh: function (frm) {
+ if (frm.doc.repayment_schedule_type == "Pro-rated calendar months") {
+ frm.set_df_property("repayment_start_date", "label", "Interest Calculation Start Date");
+ }
+
if (frm.doc.docstatus == 1) {
if (["Disbursed", "Partially Disbursed"].includes(frm.doc.status) && (!frm.doc.repay_from_salary)) {
frm.add_custom_button(__('Request Loan Closure'), function() {
@@ -103,6 +107,14 @@ frappe.ui.form.on('Loan', {
frm.trigger("toggle_fields");
},
+ repayment_schedule_type: function(frm) {
+ if (frm.doc.repayment_schedule_type == "Pro-rated calendar months") {
+ frm.set_df_property("repayment_start_date", "label", "Interest Calculation Start Date");
+ } else {
+ frm.set_df_property("repayment_start_date", "label", "Repayment Start Date");
+ }
+ },
+
loan_type: function(frm) {
frm.toggle_reqd("repayment_method", frm.doc.is_term_loan);
frm.toggle_display("repayment_method", frm.doc.is_term_loan);
diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json
index 47488f43ce..dc8b03e89d 100644
--- a/erpnext/loan_management/doctype/loan/loan.json
+++ b/erpnext/loan_management/doctype/loan/loan.json
@@ -18,6 +18,7 @@
"status",
"section_break_8",
"loan_type",
+ "repayment_schedule_type",
"loan_amount",
"rate_of_interest",
"is_secured_loan",
@@ -158,7 +159,8 @@
"depends_on": "is_term_loan",
"fieldname": "repayment_start_date",
"fieldtype": "Date",
- "label": "Repayment Start Date"
+ "label": "Repayment Start Date",
+ "mandatory_depends_on": "is_term_loan"
},
{
"fieldname": "column_break_11",
@@ -402,12 +404,20 @@
"fieldname": "is_npa",
"fieldtype": "Check",
"label": "Is NPA"
+ },
+ {
+ "depends_on": "is_term_loan",
+ "fetch_from": "loan_type.repayment_schedule_type",
+ "fieldname": "repayment_schedule_type",
+ "fieldtype": "Data",
+ "label": "Repayment Schedule Type",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-07-12 11:50:31.957360",
+ "modified": "2022-09-30 10:36:47.902903",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan",
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index d84eef6d8c..0c9c97f60f 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -7,7 +7,16 @@ import math
import frappe
from frappe import _
-from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ date_diff,
+ flt,
+ get_last_day,
+ getdate,
+ now_datetime,
+ nowdate,
+)
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry
@@ -107,30 +116,81 @@ class Loan(AccountsController):
if not self.repayment_start_date:
frappe.throw(_("Repayment Start Date is mandatory for term loans"))
+ schedule_type_details = frappe.db.get_value(
+ "Loan Type", self.loan_type, ["repayment_schedule_type", "repayment_date_on"], as_dict=1
+ )
+
self.repayment_schedule = []
payment_date = self.repayment_start_date
balance_amount = self.loan_amount
- while balance_amount > 0:
- interest_amount = flt(balance_amount * flt(self.rate_of_interest) / (12 * 100))
- principal_amount = self.monthly_repayment_amount - interest_amount
- balance_amount = flt(balance_amount + interest_amount - self.monthly_repayment_amount)
- if balance_amount < 0:
- principal_amount += balance_amount
- balance_amount = 0.0
- total_payment = principal_amount + interest_amount
- self.append(
- "repayment_schedule",
- {
- "payment_date": payment_date,
- "principal_amount": principal_amount,
- "interest_amount": interest_amount,
- "total_payment": total_payment,
- "balance_loan_amount": balance_amount,
- },
+ while balance_amount > 0:
+ interest_amount, principal_amount, balance_amount, total_payment = self.get_amounts(
+ payment_date,
+ balance_amount,
+ schedule_type_details.repayment_schedule_type,
+ schedule_type_details.repayment_date_on,
)
- next_payment_date = add_single_month(payment_date)
- payment_date = next_payment_date
+
+ if schedule_type_details.repayment_schedule_type == "Pro-rated calendar months":
+ next_payment_date = get_last_day(payment_date)
+ if schedule_type_details.repayment_date_on == "Start of the next month":
+ next_payment_date = add_days(next_payment_date, 1)
+
+ payment_date = next_payment_date
+
+ self.add_repayment_schedule_row(
+ payment_date, principal_amount, interest_amount, total_payment, balance_amount
+ )
+
+ if (
+ schedule_type_details.repayment_schedule_type == "Monthly as per repayment start date"
+ or schedule_type_details.repayment_date_on == "End of the current month"
+ ):
+ next_payment_date = add_single_month(payment_date)
+ payment_date = next_payment_date
+
+ def get_amounts(self, payment_date, balance_amount, schedule_type, repayment_date_on):
+ if schedule_type == "Monthly as per repayment start date":
+ days = 1
+ months = 12
+ else:
+ expected_payment_date = get_last_day(payment_date)
+ if repayment_date_on == "Start of the next month":
+ expected_payment_date = add_days(expected_payment_date, 1)
+
+ if expected_payment_date == payment_date:
+ # using 30 days for calculating interest for all full months
+ days = 30
+ months = 365
+ else:
+ days = date_diff(get_last_day(payment_date), payment_date)
+ months = 365
+
+ interest_amount = flt(balance_amount * flt(self.rate_of_interest) * days / (months * 100))
+ principal_amount = self.monthly_repayment_amount - interest_amount
+ balance_amount = flt(balance_amount + interest_amount - self.monthly_repayment_amount)
+ if balance_amount < 0:
+ principal_amount += balance_amount
+ balance_amount = 0.0
+
+ total_payment = principal_amount + interest_amount
+
+ return interest_amount, principal_amount, balance_amount, total_payment
+
+ def add_repayment_schedule_row(
+ self, payment_date, principal_amount, interest_amount, total_payment, balance_loan_amount
+ ):
+ self.append(
+ "repayment_schedule",
+ {
+ "payment_date": payment_date,
+ "principal_amount": principal_amount,
+ "interest_amount": interest_amount,
+ "total_payment": total_payment,
+ "balance_loan_amount": balance_loan_amount,
+ },
+ )
def set_repayment_period(self):
if self.repayment_method == "Repay Fixed Amount per Period":
diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py
index da05c8e90c..388e65d9e5 100644
--- a/erpnext/loan_management/doctype/loan/test_loan.py
+++ b/erpnext/loan_management/doctype/loan/test_loan.py
@@ -4,7 +4,16 @@
import unittest
import frappe
-from frappe.utils import add_days, add_months, add_to_date, date_diff, flt, get_datetime, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ add_to_date,
+ date_diff,
+ flt,
+ format_date,
+ get_datetime,
+ nowdate,
+)
from erpnext.loan_management.doctype.loan.loan import (
make_loan_write_off,
@@ -47,6 +56,51 @@ class TestLoan(unittest.TestCase):
loan_account="Loan Account - _TC",
interest_income_account="Interest Income Account - _TC",
penalty_income_account="Penalty Income Account - _TC",
+ repayment_schedule_type="Monthly as per repayment start date",
+ )
+
+ create_loan_type(
+ "Term Loan Type 1",
+ 12000,
+ 7.5,
+ is_term_loan=1,
+ mode_of_payment="Cash",
+ disbursement_account="Disbursement Account - _TC",
+ payment_account="Payment Account - _TC",
+ loan_account="Loan Account - _TC",
+ interest_income_account="Interest Income Account - _TC",
+ penalty_income_account="Penalty Income Account - _TC",
+ repayment_schedule_type="Monthly as per repayment start date",
+ )
+
+ create_loan_type(
+ "Term Loan Type 2",
+ 12000,
+ 7.5,
+ is_term_loan=1,
+ mode_of_payment="Cash",
+ disbursement_account="Disbursement Account - _TC",
+ payment_account="Payment Account - _TC",
+ loan_account="Loan Account - _TC",
+ interest_income_account="Interest Income Account - _TC",
+ penalty_income_account="Penalty Income Account - _TC",
+ repayment_schedule_type="Pro-rated calendar months",
+ repayment_date_on="Start of the next month",
+ )
+
+ create_loan_type(
+ "Term Loan Type 3",
+ 12000,
+ 7.5,
+ is_term_loan=1,
+ mode_of_payment="Cash",
+ disbursement_account="Disbursement Account - _TC",
+ payment_account="Payment Account - _TC",
+ loan_account="Loan Account - _TC",
+ interest_income_account="Interest Income Account - _TC",
+ penalty_income_account="Penalty Income Account - _TC",
+ repayment_schedule_type="Pro-rated calendar months",
+ repayment_date_on="End of the current month",
)
create_loan_type(
@@ -62,6 +116,7 @@ class TestLoan(unittest.TestCase):
"Loan Account - _TC",
"Interest Income Account - _TC",
"Penalty Income Account - _TC",
+ repayment_schedule_type="Monthly as per repayment start date",
)
create_loan_type(
@@ -902,6 +957,69 @@ class TestLoan(unittest.TestCase):
amounts = calculate_amounts(loan.name, add_days(last_date, 5))
self.assertEqual(flt(amounts["pending_principal_amount"], 0), 0)
+ def test_term_loan_schedule_types(self):
+ loan = create_loan(
+ self.applicant1,
+ "Term Loan Type 1",
+ 12000,
+ "Repay Over Number of Periods",
+ 12,
+ repayment_start_date="2022-10-17",
+ )
+
+ # Check for first, second and last installment date
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[0].payment_date, "dd-MM-yyyy"), "17-10-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[1].payment_date, "dd-MM-yyyy"), "17-11-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[-1].payment_date, "dd-MM-yyyy"), "17-09-2023"
+ )
+
+ loan.loan_type = "Term Loan Type 2"
+ loan.save()
+
+ # Check for first, second and last installment date
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[0].payment_date, "dd-MM-yyyy"), "01-11-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[1].payment_date, "dd-MM-yyyy"), "01-12-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[-1].payment_date, "dd-MM-yyyy"), "01-10-2023"
+ )
+
+ loan.loan_type = "Term Loan Type 3"
+ loan.save()
+
+ # Check for first, second and last installment date
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[0].payment_date, "dd-MM-yyyy"), "31-10-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[1].payment_date, "dd-MM-yyyy"), "30-11-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[-1].payment_date, "dd-MM-yyyy"), "30-09-2023"
+ )
+
+ loan.repayment_method = "Repay Fixed Amount per Period"
+ loan.monthly_repayment_amount = 1042
+ loan.save()
+
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[0].payment_date, "dd-MM-yyyy"), "31-10-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[1].payment_date, "dd-MM-yyyy"), "30-11-2022"
+ )
+ self.assertEqual(
+ format_date(loan.get("repayment_schedule")[-1].payment_date, "dd-MM-yyyy"), "30-09-2023"
+ )
+
def create_loan_scenario_for_penalty(doc):
pledge = [{"loan_security": "Test Security 1", "qty": 4000.00}]
@@ -1033,6 +1151,8 @@ def create_loan_type(
penalty_income_account=None,
repayment_method=None,
repayment_periods=None,
+ repayment_schedule_type=None,
+ repayment_date_on=None,
):
if not frappe.db.exists("Loan Type", loan_name):
@@ -1042,6 +1162,7 @@ def create_loan_type(
"company": "_Test Company",
"loan_name": loan_name,
"is_term_loan": is_term_loan,
+ "repayment_schedule_type": "Monthly as per repayment start date",
"maximum_loan_amount": maximum_loan_amount,
"rate_of_interest": rate_of_interest,
"penalty_interest_rate": penalty_interest_rate,
@@ -1056,8 +1177,14 @@ def create_loan_type(
"repayment_periods": repayment_periods,
"write_off_amount": 100,
}
- ).insert()
+ )
+ if loan_type.is_term_loan:
+ loan_type.repayment_schedule_type = repayment_schedule_type
+ if loan_type.repayment_schedule_type != "Monthly as per repayment start date":
+ loan_type.repayment_date_on = repayment_date_on
+
+ loan_type.insert()
loan_type.submit()
diff --git a/erpnext/loan_management/doctype/loan_balance_adjustment/loan_balance_adjustment.py b/erpnext/loan_management/doctype/loan_balance_adjustment/loan_balance_adjustment.py
index 0a576d6969..514a5fcfaf 100644
--- a/erpnext/loan_management/doctype/loan_balance_adjustment/loan_balance_adjustment.py
+++ b/erpnext/loan_management/doctype/loan_balance_adjustment/loan_balance_adjustment.py
@@ -99,7 +99,7 @@ class LoanBalanceAdjustment(AccountsController):
loan_account = frappe.db.get_value("Loan", self.loan, "loan_account")
remarks = "{} against loan {}".format(self.adjustment_type.capitalize(), self.loan)
if self.reference_number:
- remarks += "with reference no. {}".format(self.reference_number)
+ remarks += " with reference no. {}".format(self.reference_number)
loan_entry = {
"account": loan_account,
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
index 50926d7726..c7b5c03375 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
@@ -163,11 +163,11 @@
},
{
"fetch_from": "against_loan.disbursement_account",
+ "fetch_if_empty": 1,
"fieldname": "disbursement_account",
"fieldtype": "Link",
"label": "Disbursement Account",
- "options": "Account",
- "read_only": 1
+ "options": "Account"
},
{
"fieldname": "column_break_16",
@@ -185,7 +185,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-02-17 18:23:44.157598",
+ "modified": "2022-08-04 17:16:04.922444",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Disbursement",
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index 0c2042ba50..51bf327c81 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -191,7 +191,9 @@ def get_total_pledged_security_value(loan):
for security, qty in pledged_securities.items():
after_haircut_percentage = 100 - hair_cut_map.get(security)
- security_value += (loan_security_price_map.get(security) * qty * after_haircut_percentage) / 100
+ security_value += (
+ loan_security_price_map.get(security, 0) * qty * after_haircut_percentage
+ ) / 100
return security_value
@@ -209,6 +211,9 @@ def get_disbursal_amount(loan, on_current_security_price=0):
"loan_amount",
"disbursed_amount",
"total_payment",
+ "debit_adjustment_amount",
+ "credit_adjustment_amount",
+ "refund_amount",
"total_principal_paid",
"total_interest_payable",
"status",
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
index 0aeb448918..cac3f1f0f3 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
@@ -135,7 +135,11 @@ def calculate_accrual_amount_for_demand_loans(
def make_accrual_interest_entry_for_demand_loans(
posting_date, process_loan_interest, open_loans=None, loan_type=None, accrual_type="Regular"
):
- query_filters = {"status": ("in", ["Disbursed", "Partially Disbursed"]), "docstatus": 1}
+ query_filters = {
+ "status": ("in", ["Disbursed", "Partially Disbursed"]),
+ "docstatus": 1,
+ "is_term_loan": 0,
+ }
if loan_type:
query_filters.update({"loan_type": loan_type})
@@ -147,6 +151,9 @@ def make_accrual_interest_entry_for_demand_loans(
"name",
"total_payment",
"total_amount_paid",
+ "debit_adjustment_amount",
+ "credit_adjustment_amount",
+ "refund_amount",
"loan_account",
"interest_income_account",
"loan_amount",
@@ -229,6 +236,7 @@ def get_term_loans(date, term_loan=None, loan_type=None):
AND l.is_term_loan =1
AND rs.payment_date <= %s
AND rs.is_accrued=0 {0}
+ AND rs.principal_amount > 0
AND l.status = 'Disbursed'
ORDER BY rs.payment_date""".format(
condition
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
index 76dc8b462e..3e7dc28f71 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
@@ -264,11 +264,11 @@
},
{
"fetch_from": "against_loan.payment_account",
+ "fetch_if_empty": 1,
"fieldname": "payment_account",
"fieldtype": "Link",
"label": "Repayment Account",
- "options": "Account",
- "read_only": 1
+ "options": "Account"
},
{
"fieldname": "column_break_36",
@@ -294,7 +294,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-06-21 10:10:07.742298",
+ "modified": "2022-08-04 17:13:51.964203",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Repayment",
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 07a1d0d850..8a185f8683 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -149,6 +149,9 @@ class LoanRepayment(AccountsController):
"status",
"is_secured_loan",
"total_payment",
+ "debit_adjustment_amount",
+ "credit_adjustment_amount",
+ "refund_amount",
"loan_amount",
"disbursed_amount",
"total_interest_payable",
@@ -398,7 +401,7 @@ class LoanRepayment(AccountsController):
remarks = "Repayment against loan " + self.against_loan
if self.reference_number:
- remarks += "with reference no. {}".format(self.reference_number)
+ remarks += " with reference no. {}".format(self.reference_number)
if hasattr(self, "repay_from_salary") and self.repay_from_salary:
payment_account = self.payroll_payable_account
@@ -516,6 +519,8 @@ def get_accrued_interest_entries(against_loan, posting_date=None):
if not posting_date:
posting_date = getdate()
+ precision = cint(frappe.db.get_default("currency_precision")) or 2
+
unpaid_accrued_entries = frappe.db.sql(
"""
SELECT name, posting_date, interest_amount - paid_interest_amount as interest_amount,
@@ -536,6 +541,13 @@ def get_accrued_interest_entries(against_loan, posting_date=None):
as_dict=1,
)
+ # Skip entries with zero interest amount & payable principal amount
+ unpaid_accrued_entries = [
+ d
+ for d in unpaid_accrued_entries
+ if flt(d.interest_amount, precision) > 0 or flt(d.payable_principal_amount, precision) > 0
+ ]
+
return unpaid_accrued_entries
@@ -564,8 +576,8 @@ def regenerate_repayment_schedule(loan, cancel=0):
loan_doc = frappe.get_doc("Loan", loan)
next_accrual_date = None
accrued_entries = 0
- last_repayment_amount = 0
- last_balance_amount = 0
+ last_repayment_amount = None
+ last_balance_amount = None
for term in reversed(loan_doc.get("repayment_schedule")):
if not term.is_accrued:
@@ -573,9 +585,9 @@ def regenerate_repayment_schedule(loan, cancel=0):
loan_doc.remove(term)
else:
accrued_entries += 1
- if not last_repayment_amount:
+ if last_repayment_amount is None:
last_repayment_amount = term.total_payment
- if not last_balance_amount:
+ if last_balance_amount is None:
last_balance_amount = term.balance_loan_amount
loan_doc.save()
@@ -732,6 +744,7 @@ def get_amounts(amounts, against_loan, posting_date):
)
amounts["pending_accrual_entries"] = pending_accrual_entries
amounts["unaccrued_interest"] = flt(unaccrued_interest, precision)
+ amounts["written_off_amount"] = flt(against_loan_doc.written_off_amount, precision)
if final_due_date:
amounts["due_date"] = final_due_date
diff --git a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
index 0ab7beb0fc..15a9c4a85e 100644
--- a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
+++ b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
@@ -57,6 +57,9 @@ class LoanSecurityUnpledge(Document):
self.loan,
[
"total_payment",
+ "debit_adjustment_amount",
+ "credit_adjustment_amount",
+ "refund_amount",
"total_principal_paid",
"loan_amount",
"total_interest_payable",
diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.json b/erpnext/loan_management/doctype/loan_type/loan_type.json
index 00337e4b4c..5cc9464585 100644
--- a/erpnext/loan_management/doctype/loan_type/loan_type.json
+++ b/erpnext/loan_management/doctype/loan_type/loan_type.json
@@ -16,6 +16,8 @@
"company",
"is_term_loan",
"disabled",
+ "repayment_schedule_type",
+ "repayment_date_on",
"description",
"account_details_section",
"mode_of_payment",
@@ -157,12 +159,30 @@
"label": "Disbursement Account",
"options": "Account",
"reqd": 1
+ },
+ {
+ "depends_on": "is_term_loan",
+ "description": "The schedule type that will be used for generating the term loan schedules (will affect the payment date and monthly repayment amount)",
+ "fieldname": "repayment_schedule_type",
+ "fieldtype": "Select",
+ "label": "Repayment Schedule Type",
+ "mandatory_depends_on": "is_term_loan",
+ "options": "\nMonthly as per repayment start date\nPro-rated calendar months"
+ },
+ {
+ "depends_on": "eval:doc.repayment_schedule_type == \"Pro-rated calendar months\"",
+ "description": "Select whether the repayment date should be the end of the current month or start of the upcoming month",
+ "fieldname": "repayment_date_on",
+ "fieldtype": "Select",
+ "label": "Repayment Date On",
+ "mandatory_depends_on": "eval:doc.repayment_schedule_type == \"Pro-rated calendar months\"",
+ "options": "\nStart of the next month\nEnd of the current month"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-25 16:23:57.009349",
+ "modified": "2022-10-22 17:43:03.954201",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Type",
diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
index 25aecf673b..ae483f9759 100644
--- a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
+++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
@@ -9,6 +9,9 @@ from frappe.utils import cint, flt, getdate
import erpnext
from erpnext.accounts.general_ledger import make_gl_entries
from erpnext.controllers.accounts_controller import AccountsController
+from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
+ get_pending_principal_amount,
+)
class LoanWriteOff(AccountsController):
@@ -22,16 +25,26 @@ class LoanWriteOff(AccountsController):
def validate_write_off_amount(self):
precision = cint(frappe.db.get_default("currency_precision")) or 2
- total_payment, principal_paid, interest_payable, written_off_amount = frappe.get_value(
+
+ loan_details = frappe.get_value(
"Loan",
self.loan,
- ["total_payment", "total_principal_paid", "total_interest_payable", "written_off_amount"],
+ [
+ "total_payment",
+ "debit_adjustment_amount",
+ "credit_adjustment_amount",
+ "refund_amount",
+ "total_principal_paid",
+ "loan_amount",
+ "total_interest_payable",
+ "written_off_amount",
+ "disbursed_amount",
+ "status",
+ ],
+ as_dict=1,
)
- pending_principal_amount = flt(
- flt(total_payment) - flt(interest_payable) - flt(principal_paid) - flt(written_off_amount),
- precision,
- )
+ pending_principal_amount = flt(get_pending_principal_amount(loan_details), precision)
if self.write_off_amount > pending_principal_amount:
frappe.throw(_("Write off amount cannot be greater than pending principal amount"))
diff --git a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py
index 81464a36c3..25c72d91a7 100644
--- a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py
@@ -57,7 +57,7 @@ def process_loan_interest_accrual_for_demand_loans(
def process_loan_interest_accrual_for_term_loans(posting_date=None, loan_type=None, loan=None):
- if not term_loan_accrual_pending(posting_date or nowdate()):
+ if not term_loan_accrual_pending(posting_date or nowdate(), loan=loan):
return
loan_process = frappe.new_doc("Process Loan Interest Accrual")
@@ -71,9 +71,12 @@ def process_loan_interest_accrual_for_term_loans(posting_date=None, loan_type=No
return loan_process.name
-def term_loan_accrual_pending(date):
- pending_accrual = frappe.db.get_value(
- "Repayment Schedule", {"payment_date": ("<=", date), "is_accrued": 0}
- )
+def term_loan_accrual_pending(date, loan=None):
+ filters = {"payment_date": ("<=", date), "is_accrued": 0}
+
+ if loan:
+ filters.update({"parent": loan})
+
+ pending_accrual = frappe.db.get_value("Repayment Schedule", filters)
return pending_accrual
diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js
index a227b6d797..458c79a1ea 100644
--- a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js
+++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js
@@ -11,6 +11,40 @@ frappe.query_reports["Loan Interest Report"] = {
"options": "Company",
"default": frappe.defaults.get_user_default("Company"),
"reqd": 1
- }
+ },
+ {
+ "fieldname":"applicant_type",
+ "label": __("Applicant Type"),
+ "fieldtype": "Select",
+ "options": ["Customer", "Employee"],
+ "reqd": 1,
+ "default": "Customer",
+ on_change: function() {
+ frappe.query_report.set_filter_value('applicant', "");
+ }
+ },
+ {
+ "fieldname": "applicant",
+ "label": __("Applicant"),
+ "fieldtype": "Dynamic Link",
+ "get_options": function() {
+ var applicant_type = frappe.query_report.get_filter_value('applicant_type');
+ var applicant = frappe.query_report.get_filter_value('applicant');
+ if(applicant && !applicant_type) {
+ frappe.throw(__("Please select Applicant Type first"));
+ }
+ return applicant_type;
+ }
+ },
+ {
+ "fieldname":"from_date",
+ "label": __("From Date"),
+ "fieldtype": "Date",
+ },
+ {
+ "fieldname":"to_date",
+ "label": __("From Date"),
+ "fieldtype": "Date",
+ },
]
};
diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py
index 9186ce6174..58a7880a45 100644
--- a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py
+++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py
@@ -13,12 +13,12 @@ from erpnext.loan_management.report.applicant_wise_loan_security_exposure.applic
def execute(filters=None):
- columns = get_columns(filters)
+ columns = get_columns()
data = get_active_loan_details(filters)
return columns, data
-def get_columns(filters):
+def get_columns():
columns = [
{"label": _("Loan"), "fieldname": "loan", "fieldtype": "Link", "options": "Loan", "width": 160},
{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 160},
@@ -70,6 +70,13 @@ def get_columns(filters):
"options": "currency",
"width": 120,
},
+ {
+ "label": _("Accrued Principal"),
+ "fieldname": "accrued_principal",
+ "fieldtype": "Currency",
+ "options": "currency",
+ "width": 120,
+ },
{
"label": _("Total Repayment"),
"fieldname": "total_repayment",
@@ -137,11 +144,16 @@ def get_columns(filters):
def get_active_loan_details(filters):
-
- filter_obj = {"status": ("!=", "Closed")}
+ filter_obj = {
+ "status": ("!=", "Closed"),
+ "docstatus": 1,
+ }
if filters.get("company"):
filter_obj.update({"company": filters.get("company")})
+ if filters.get("applicant"):
+ filter_obj.update({"applicant": filters.get("applicant")})
+
loan_details = frappe.get_all(
"Loan",
fields=[
@@ -167,8 +179,8 @@ def get_active_loan_details(filters):
sanctioned_amount_map = get_sanctioned_amount_map()
penal_interest_rate_map = get_penal_interest_rate_map()
- payments = get_payments(loan_list)
- accrual_map = get_interest_accruals(loan_list)
+ payments = get_payments(loan_list, filters)
+ accrual_map = get_interest_accruals(loan_list, filters)
currency = erpnext.get_company_currency(filters.get("company"))
for loan in loan_details:
@@ -183,6 +195,7 @@ def get_active_loan_details(filters):
- flt(loan.written_off_amount),
"total_repayment": flt(payments.get(loan.loan)),
"accrued_interest": flt(accrual_map.get(loan.loan, {}).get("accrued_interest")),
+ "accrued_principal": flt(accrual_map.get(loan.loan, {}).get("accrued_principal")),
"interest_outstanding": flt(accrual_map.get(loan.loan, {}).get("interest_outstanding")),
"penalty": flt(accrual_map.get(loan.loan, {}).get("penalty")),
"penalty_interest": penal_interest_rate_map.get(loan.loan_type),
@@ -212,20 +225,35 @@ def get_sanctioned_amount_map():
)
-def get_payments(loans):
+def get_payments(loans, filters):
+ query_filters = {"against_loan": ("in", loans)}
+
+ if filters.get("from_date"):
+ query_filters.update({"posting_date": (">=", filters.get("from_date"))})
+
+ if filters.get("to_date"):
+ query_filters.update({"posting_date": ("<=", filters.get("to_date"))})
+
return frappe._dict(
frappe.get_all(
"Loan Repayment",
fields=["against_loan", "sum(amount_paid)"],
- filters={"against_loan": ("in", loans)},
+ filters=query_filters,
group_by="against_loan",
as_list=1,
)
)
-def get_interest_accruals(loans):
+def get_interest_accruals(loans, filters):
accrual_map = {}
+ query_filters = {"loan": ("in", loans)}
+
+ if filters.get("from_date"):
+ query_filters.update({"posting_date": (">=", filters.get("from_date"))})
+
+ if filters.get("to_date"):
+ query_filters.update({"posting_date": ("<=", filters.get("to_date"))})
interest_accruals = frappe.get_all(
"Loan Interest Accrual",
@@ -236,8 +264,9 @@ def get_interest_accruals(loans):
"penalty_amount",
"paid_interest_amount",
"accrual_type",
+ "payable_principal_amount",
],
- filters={"loan": ("in", loans)},
+ filters=query_filters,
order_by="posting_date desc",
)
@@ -246,6 +275,7 @@ def get_interest_accruals(loans):
entry.loan,
{
"accrued_interest": 0.0,
+ "accrued_principal": 0.0,
"undue_interest": 0.0,
"interest_outstanding": 0.0,
"last_accrual_date": "",
@@ -270,6 +300,7 @@ def get_interest_accruals(loans):
accrual_map[entry.loan]["undue_interest"] += entry.interest_amount - entry.paid_interest_amount
accrual_map[entry.loan]["accrued_interest"] += entry.interest_amount
+ accrual_map[entry.loan]["accrued_principal"] += entry.payable_principal_amount
if last_accrual_date and getdate(entry.posting_date) == last_accrual_date:
accrual_map[entry.loan]["penalty"] = entry.penalty_amount
diff --git a/erpnext/loan_management/workspace/loans/loans.json b/erpnext/loan_management/workspace/loans/loans.json
new file mode 100644
index 0000000000..c65be4efae
--- /dev/null
+++ b/erpnext/loan_management/workspace/loans/loans.json
@@ -0,0 +1,315 @@
+{
+ "charts": [],
+ "content": "[{\"id\":\"_38WStznya\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"t7o_K__1jB\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan Application\",\"col\":3}},{\"id\":\"IRiNDC6w1p\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan\",\"col\":3}},{\"id\":\"xbbo0FYbq0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"7ZL4Bro-Vi\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"yhyioTViZ3\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"oYFn4b1kSw\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan\",\"col\":4}},{\"id\":\"vZepJF5tl9\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Processes\",\"col\":4}},{\"id\":\"k-393Mjhqe\",\"type\":\"card\",\"data\":{\"card_name\":\"Disbursement and Repayment\",\"col\":4}},{\"id\":\"6crJ0DBiBJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Security\",\"col\":4}},{\"id\":\"Um5YwxVLRJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]",
+ "creation": "2020-03-12 16:35:55.299820",
+ "docstatus": 0,
+ "doctype": "Workspace",
+ "for_user": "",
+ "hide_custom": 0,
+ "icon": "loan",
+ "idx": 0,
+ "is_hidden": 0,
+ "label": "Loans",
+ "links": [
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan",
+ "link_count": 0,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Type",
+ "link_count": 0,
+ "link_to": "Loan Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Application",
+ "link_count": 0,
+ "link_to": "Loan Application",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan",
+ "link_count": 0,
+ "link_to": "Loan",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Processes",
+ "link_count": 0,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Process Loan Security Shortfall",
+ "link_count": 0,
+ "link_to": "Process Loan Security Shortfall",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Process Loan Interest Accrual",
+ "link_count": 0,
+ "link_to": "Process Loan Interest Accrual",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Disbursement and Repayment",
+ "link_count": 0,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Disbursement",
+ "link_count": 0,
+ "link_to": "Loan Disbursement",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Repayment",
+ "link_count": 0,
+ "link_to": "Loan Repayment",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Write Off",
+ "link_count": 0,
+ "link_to": "Loan Write Off",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Interest Accrual",
+ "link_count": 0,
+ "link_to": "Loan Interest Accrual",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security",
+ "link_count": 0,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security Type",
+ "link_count": 0,
+ "link_to": "Loan Security Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security Price",
+ "link_count": 0,
+ "link_to": "Loan Security Price",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security",
+ "link_count": 0,
+ "link_to": "Loan Security",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security Pledge",
+ "link_count": 0,
+ "link_to": "Loan Security Pledge",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security Unpledge",
+ "link_count": 0,
+ "link_to": "Loan Security Unpledge",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Loan Security Shortfall",
+ "link_count": 0,
+ "link_to": "Loan Security Shortfall",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Reports",
+ "link_count": 6,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Loan Repayment and Closure",
+ "link_count": 0,
+ "link_to": "Loan Repayment and Closure",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Loan Security Status",
+ "link_count": 0,
+ "link_to": "Loan Security Status",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Loan Interest Report",
+ "link_count": 0,
+ "link_to": "Loan Interest Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Loan Security Exposure",
+ "link_count": 0,
+ "link_to": "Loan Security Exposure",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Applicant-Wise Loan Security Exposure",
+ "link_count": 0,
+ "link_to": "Applicant-Wise Loan Security Exposure",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Loan Security Status",
+ "link_count": 0,
+ "link_to": "Loan Security Status",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ }
+ ],
+ "modified": "2023-01-31 19:47:13.114415",
+ "modified_by": "Administrator",
+ "module": "Loan Management",
+ "name": "Loans",
+ "owner": "Administrator",
+ "parent_page": "",
+ "public": 1,
+ "quick_lists": [],
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 16.0,
+ "shortcuts": [
+ {
+ "color": "Green",
+ "format": "{} Open",
+ "label": "Loan Application",
+ "link_to": "Loan Application",
+ "stats_filter": "{ \"status\": \"Open\" }",
+ "type": "DocType"
+ },
+ {
+ "label": "Loan",
+ "link_to": "Loan",
+ "type": "DocType"
+ },
+ {
+ "doc_view": "",
+ "label": "Dashboard",
+ "link_to": "Loan Dashboard",
+ "type": "Dashboard"
+ }
+ ],
+ "title": "Loans"
+}
\ No newline at end of file
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
index 09d4429712..95e2d694a5 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
@@ -119,7 +119,7 @@ class MaintenanceSchedule(TransactionBase):
event.add_participant(self.doctype, self.name)
event.insert(ignore_permissions=1)
- frappe.db.set(self, "status", "Submitted")
+ self.db_set("status", "Submitted")
def create_schedule_list(self, start_date, end_date, no_of_visit, sales_person):
schedule_list = []
@@ -245,7 +245,7 @@ class MaintenanceSchedule(TransactionBase):
self.generate_schedule()
def on_update(self):
- frappe.db.set(self, "status", "Draft")
+ self.db_set("status", "Draft")
def update_amc_date(self, serial_nos, amc_expiry_date=None):
for serial_no in serial_nos:
@@ -344,7 +344,7 @@ class MaintenanceSchedule(TransactionBase):
if d.serial_no:
serial_nos = get_valid_serial_nos(d.serial_no)
self.update_amc_date(serial_nos)
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
delete_events(self.doctype, self.name)
def on_trash(self):
@@ -415,7 +415,7 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No
},
"Maintenance Schedule Item": {
"doctype": "Maintenance Visit Purpose",
- "condition": lambda doc: doc.item_name == item_name,
+ "condition": lambda doc: doc.item_name == item_name if item_name else True,
"field_map": {"sales_person": "service_person"},
"postprocess": update_serial,
},
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
index 66f4426a0b..b900b216e6 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
@@ -10,9 +10,6 @@ from erpnext.utilities.transaction_base import TransactionBase
class MaintenanceVisit(TransactionBase):
- def get_feed(self):
- return _("To {0}").format(self.customer_name)
-
def validate_serial_no(self):
for d in self.get("purposes"):
if d.serial_no and not frappe.db.exists("Serial No", d.serial_no):
@@ -125,12 +122,12 @@ class MaintenanceVisit(TransactionBase):
def on_submit(self):
self.update_customer_issue(1)
- frappe.db.set(self, "status", "Submitted")
+ self.db_set("status", "Submitted")
self.update_status_and_actual_date()
def on_cancel(self):
self.check_if_last_visit()
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
self.update_status_and_actual_date(cancel=True)
def on_update(self):
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index ecad41fe7b..4304193afa 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -4,7 +4,7 @@
frappe.provide("erpnext.bom");
frappe.ui.form.on("BOM", {
- setup: function(frm) {
+ setup(frm) {
frm.custom_make_buttons = {
'Work Order': 'Work Order',
'Quality Inspection': 'Quality Inspection'
@@ -65,11 +65,25 @@ frappe.ui.form.on("BOM", {
});
},
+ validate: function(frm) {
+ if (frm.doc.fg_based_operating_cost && frm.doc.with_operations) {
+ frappe.throw({message: __("Please check either with operations or FG Based Operating Cost."), title: __("Mandatory")});
+ }
+ },
+
+ with_operations: function(frm) {
+ frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0);
+ },
+
+ fg_based_operating_cost: function(frm) {
+ frm.set_df_property("with_operations", "hidden", frm.doc.fg_based_operating_cost ? 1 : 0);
+ },
+
onload_post_render: function(frm) {
frm.get_field("items").grid.set_multiple_add("item_code", "qty");
},
- refresh: function(frm) {
+ refresh(frm) {
frm.toggle_enable("item", frm.doc.__islocal);
frm.set_indicator_formatter('item_code',
@@ -152,7 +166,7 @@ frappe.ui.form.on("BOM", {
}
},
- make_work_order: function(frm) {
+ make_work_order(frm) {
frm.events.setup_variant_prompt(frm, "Work Order", (frm, item, data, variant_items) => {
frappe.call({
method: "erpnext.manufacturing.doctype.work_order.work_order.make_work_order",
@@ -164,7 +178,7 @@ frappe.ui.form.on("BOM", {
variant_items: variant_items
},
freeze: true,
- callback: function(r) {
+ callback(r) {
if(r.message) {
let doc = frappe.model.sync(r.message)[0];
frappe.set_route("Form", doc.doctype, doc.name);
@@ -174,7 +188,7 @@ frappe.ui.form.on("BOM", {
});
},
- make_variant_bom: function(frm) {
+ make_variant_bom(frm) {
frm.events.setup_variant_prompt(frm, "Variant BOM", (frm, item, data, variant_items) => {
frappe.call({
method: "erpnext.manufacturing.doctype.bom.bom.make_variant_bom",
@@ -185,7 +199,7 @@ frappe.ui.form.on("BOM", {
variant_items: variant_items
},
freeze: true,
- callback: function(r) {
+ callback(r) {
if(r.message) {
let doc = frappe.model.sync(r.message)[0];
frappe.set_route("Form", doc.doctype, doc.name);
@@ -195,7 +209,7 @@ frappe.ui.form.on("BOM", {
}, true);
},
- setup_variant_prompt: function(frm, title, callback, skip_qty_field) {
+ setup_variant_prompt(frm, title, callback, skip_qty_field) {
const fields = [];
if (frm.doc.has_variants) {
@@ -205,7 +219,7 @@ frappe.ui.form.on("BOM", {
fieldname: 'item',
options: "Item",
reqd: 1,
- get_query: function() {
+ get_query() {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
@@ -273,7 +287,7 @@ frappe.ui.form.on("BOM", {
fieldtype: "Link",
in_list_view: 1,
reqd: 1,
- get_query: function(data) {
+ get_query(data) {
if (!data.item_code) {
frappe.throw(__("Select template item"));
}
@@ -308,7 +322,7 @@ frappe.ui.form.on("BOM", {
],
in_place_edit: true,
data: [],
- get_data: function () {
+ get_data () {
return [];
},
});
@@ -343,14 +357,14 @@ frappe.ui.form.on("BOM", {
}
},
- make_quality_inspection: function(frm) {
+ make_quality_inspection(frm) {
frappe.model.open_mapped_doc({
method: "erpnext.stock.doctype.quality_inspection.quality_inspection.make_quality_inspection",
frm: frm
})
},
- update_cost: function(frm, save_doc=false) {
+ update_cost(frm, save_doc=false) {
return frappe.call({
doc: frm.doc,
method: "update_cost",
@@ -360,26 +374,26 @@ frappe.ui.form.on("BOM", {
save: save_doc,
from_child_bom: false
},
- callback: function(r) {
+ callback(r) {
refresh_field("items");
if(!r.exc) frm.refresh_fields();
}
});
},
- rm_cost_as_per: function(frm) {
+ rm_cost_as_per(frm) {
if (in_list(["Valuation Rate", "Last Purchase Rate"], frm.doc.rm_cost_as_per)) {
frm.set_value("plc_conversion_rate", 1.0);
}
},
- routing: function(frm) {
+ routing(frm) {
if (frm.doc.routing) {
frappe.call({
doc: frm.doc,
method: "get_routing",
freeze: true,
- callback: function(r) {
+ callback(r) {
if (!r.exc) {
frm.refresh_fields();
erpnext.bom.calculate_op_cost(frm.doc);
@@ -388,6 +402,16 @@ frappe.ui.form.on("BOM", {
}
});
}
+ },
+
+ process_loss_percentage(frm) {
+ let qty = 0.0
+ if (frm.doc.process_loss_percentage) {
+ qty = (frm.doc.quantity * frm.doc.process_loss_percentage) / 100;
+ }
+
+ frm.set_value("process_loss_qty", qty);
+ frm.set_value("add_process_loss_cost_in_fg", qty ? 1: 0);
}
});
@@ -479,10 +503,6 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) {
},
callback: function(r) {
d = locals[cdt][cdn];
- if (d.is_process_loss) {
- r.message.rate = 0;
- r.message.base_rate = 0;
- }
$.extend(d, r.message);
refresh_field("items");
@@ -526,18 +546,25 @@ erpnext.bom.update_cost = function(doc) {
};
erpnext.bom.calculate_op_cost = function(doc) {
- var op = doc.operations || [];
doc.operating_cost = 0.0;
doc.base_operating_cost = 0.0;
- for(var i=0;i {
+ let operating_cost = flt(flt(item.hour_rate) * flt(item.time_in_mins) / 60, 2);
+ let base_operating_cost = flt(operating_cost * doc.conversion_rate, 2);
+ frappe.model.set_value('BOM Operation',item.name, {
+ "operating_cost": operating_cost,
+ "base_operating_cost": base_operating_cost
+ });
- doc.operating_cost += operating_cost;
- doc.base_operating_cost += base_operating_cost;
+ doc.operating_cost += operating_cost;
+ doc.base_operating_cost += base_operating_cost;
+ });
+ } else if(doc.fg_based_operating_cost) {
+ let total_operating_cost = doc.quantity * flt(doc.operating_cost_per_bom_quantity);
+ doc.operating_cost = total_operating_cost;
+ doc.base_operating_cost = flt(total_operating_cost * doc.conversion_rate, 2);
}
refresh_field(['operating_cost', 'base_operating_cost']);
};
@@ -717,10 +744,6 @@ frappe.tour['BOM'] = [
frappe.ui.form.on("BOM Scrap Item", {
item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn];
- if (item_code === frm.doc.item) {
- locals[cdt][cdn].is_process_loss = 1;
- trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code);
- }
},
});
diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json
index 0b44196940..db699b94d8 100644
--- a/erpnext/manufacturing/doctype/bom/bom.json
+++ b/erpnext/manufacturing/doctype/bom/bom.json
@@ -6,6 +6,7 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
+ "production_item_tab",
"item",
"company",
"item_name",
@@ -19,28 +20,33 @@
"quantity",
"image",
"currency_detail",
- "currency",
- "conversion_rate",
- "column_break_12",
"rm_cost_as_per",
"buying_price_list",
"price_list_currency",
"plc_conversion_rate",
+ "column_break_ivyw",
+ "currency",
+ "conversion_rate",
"section_break_21",
+ "operations_section_section",
"with_operations",
"column_break_23",
"transfer_material_against",
"routing",
+ "fg_based_operating_cost",
+ "fg_based_section_section",
+ "operating_cost_per_bom_quantity",
"operations_section",
"operations",
"materials_section",
- "inspection_required",
- "quality_inspection_template",
- "column_break_31",
- "section_break_33",
"items",
"scrap_section",
+ "scrap_items_section",
"scrap_items",
+ "process_loss_section",
+ "process_loss_percentage",
+ "column_break_ssj2",
+ "process_loss_qty",
"costing",
"operating_cost",
"raw_material_cost",
@@ -52,10 +58,14 @@
"column_break_26",
"total_cost",
"base_total_cost",
- "section_break_25",
+ "more_info_tab",
"description",
"column_break_27",
"has_variants",
+ "quality_inspection_section_break",
+ "inspection_required",
+ "column_break_dxp7",
+ "quality_inspection_template",
"section_break0",
"exploded_items",
"website_section",
@@ -68,7 +78,8 @@
"show_items",
"show_operations",
"web_long_description",
- "amended_from"
+ "amended_from",
+ "connections_tab"
],
"fields": [
{
@@ -183,7 +194,7 @@
{
"fieldname": "currency_detail",
"fieldtype": "Section Break",
- "label": "Currency and Price List"
+ "label": "Cost Configuration"
},
{
"fieldname": "company",
@@ -208,10 +219,6 @@
"precision": "9",
"reqd": 1
},
- {
- "fieldname": "column_break_12",
- "fieldtype": "Column Break"
- },
{
"fieldname": "currency",
"fieldtype": "Link",
@@ -261,7 +268,7 @@
{
"fieldname": "materials_section",
"fieldtype": "Section Break",
- "label": "Materials",
+ "label": "Raw Materials",
"oldfieldtype": "Section Break"
},
{
@@ -276,8 +283,8 @@
{
"collapsible": 1,
"fieldname": "scrap_section",
- "fieldtype": "Section Break",
- "label": "Scrap"
+ "fieldtype": "Tab Break",
+ "label": "Scrap & Process Loss"
},
{
"fieldname": "scrap_items",
@@ -287,7 +294,7 @@
},
{
"fieldname": "costing",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Costing",
"oldfieldtype": "Section Break"
},
@@ -379,10 +386,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "section_break_25",
- "fieldtype": "Section Break"
- },
{
"fetch_from": "item.description",
"fieldname": "description",
@@ -478,8 +481,8 @@
},
{
"fieldname": "section_break_21",
- "fieldtype": "Section Break",
- "label": "Operations"
+ "fieldtype": "Tab Break",
+ "label": "Operations & Materials"
},
{
"fieldname": "column_break_23",
@@ -511,6 +514,7 @@
"fetch_from": "item.has_variants",
"fieldname": "has_variants",
"fieldtype": "Check",
+ "hidden": 1,
"in_list_view": 1,
"label": "Has Variants",
"no_copy": 1,
@@ -518,13 +522,82 @@
"read_only": 1
},
{
- "fieldname": "column_break_31",
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "operations_section_section",
+ "fieldtype": "Section Break",
+ "label": "Operations"
+ },
+ {
+ "fieldname": "process_loss_section",
+ "fieldtype": "Section Break",
+ "label": "Process Loss"
+ },
+ {
+ "fieldname": "process_loss_percentage",
+ "fieldtype": "Percent",
+ "label": "% Process Loss"
+ },
+ {
+ "fieldname": "process_loss_qty",
+ "fieldtype": "Float",
+ "label": "Process Loss Qty",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_ssj2",
"fieldtype": "Column Break"
},
{
- "fieldname": "section_break_33",
+ "fieldname": "more_info_tab",
+ "fieldtype": "Tab Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "column_break_dxp7",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "quality_inspection_section_break",
"fieldtype": "Section Break",
- "hide_border": 1
+ "label": "Quality Inspection"
+ },
+ {
+ "fieldname": "production_item_tab",
+ "fieldtype": "Tab Break",
+ "label": "Production Item"
+ },
+ {
+ "fieldname": "column_break_ivyw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "scrap_items_section",
+ "fieldtype": "Section Break",
+ "hide_border": 1,
+ "label": "Scrap Items"
+ },
+ {
+ "default": "0",
+ "fieldname": "fg_based_operating_cost",
+ "fieldtype": "Check",
+ "label": "FG based Operating Cost"
+ },
+ {
+ "depends_on": "fg_based_operating_cost",
+ "fieldname": "fg_based_section_section",
+ "fieldtype": "Section Break",
+ "label": "FG Based Operating Cost Section"
+ },
+ {
+ "depends_on": "fg_based_operating_cost",
+ "fieldname": "operating_cost_per_bom_quantity",
+ "fieldtype": "Currency",
+ "label": "Operating Cost Per BOM Quantity"
}
],
"icon": "fa fa-sitemap",
@@ -532,7 +605,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
- "modified": "2022-01-30 21:27:54.727298",
+ "modified": "2023-02-13 17:31:37.504565",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index b29f6710e1..8ab79e68be 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -189,10 +189,11 @@ class BOM(WebsiteGenerator):
self.validate_transfer_against()
self.set_routing_operations()
self.validate_operations()
- self.update_exploded_items(save=False)
self.calculate_cost()
+ self.update_exploded_items(save=False)
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
+ self.set_process_loss_qty()
self.validate_scrap_items()
def get_context(self, context):
@@ -206,8 +207,8 @@ class BOM(WebsiteGenerator):
self.manage_default_bom()
def on_cancel(self):
- frappe.db.set(self, "is_active", 0)
- frappe.db.set(self, "is_default", 0)
+ self.db_set("is_active", 0)
+ self.db_set("is_default", 0)
# check if used in any other bom
self.validate_bom_links()
@@ -233,6 +234,7 @@ class BOM(WebsiteGenerator):
"sequence_id",
"operation",
"workstation",
+ "workstation_type",
"description",
"time_in_mins",
"batch_size",
@@ -385,6 +387,7 @@ class BOM(WebsiteGenerator):
if self.docstatus == 2:
return
+ self.flags.cost_updated = False
existing_bom_cost = self.total_cost
if self.docstatus == 1:
@@ -407,7 +410,11 @@ class BOM(WebsiteGenerator):
frappe.get_doc("BOM", bom).update_cost(from_child_bom=True)
if not from_child_bom:
- frappe.msgprint(_("Cost Updated"), alert=True)
+ msg = "Cost Updated"
+ if not self.flags.cost_updated:
+ msg = "No changes in cost found"
+
+ frappe.msgprint(_(msg), alert=True)
def update_parent_cost(self):
if self.total_cost:
@@ -444,10 +451,10 @@ class BOM(WebsiteGenerator):
not frappe.db.exists(dict(doctype="BOM", docstatus=1, item=self.item, is_default=1))
and self.is_active
):
- frappe.db.set(self, "is_default", 1)
+ self.db_set("is_default", 1)
frappe.db.set_value("Item", self.item, "default_bom", self.name)
else:
- frappe.db.set(self, "is_default", 0)
+ self.db_set("is_default", 0)
item = frappe.get_doc("Item", self.item)
if item.default_bom == self.name:
frappe.db.set_value("Item", self.item, "default_bom", None)
@@ -593,27 +600,40 @@ class BOM(WebsiteGenerator):
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
+ old_cost = self.total_cost
+
self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost
self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost
)
+ if self.total_cost != old_cost:
+ self.flags.cost_updated = True
+
def calculate_op_cost(self, update_hour_rate=False):
"""Update workstation rate and calculates totals"""
self.operating_cost = 0
self.base_operating_cost = 0
- for d in self.get("operations"):
- if d.workstation:
- self.update_rate_and_time(d, update_hour_rate)
+ if self.get("with_operations"):
+ for d in self.get("operations"):
+ if d.workstation:
+ self.update_rate_and_time(d, update_hour_rate)
- operating_cost = d.operating_cost
- base_operating_cost = d.base_operating_cost
- if d.set_cost_based_on_bom_qty:
- operating_cost = flt(d.cost_per_unit) * flt(self.quantity)
- base_operating_cost = flt(d.base_cost_per_unit) * flt(self.quantity)
+ operating_cost = d.operating_cost
+ base_operating_cost = d.base_operating_cost
+ if d.set_cost_based_on_bom_qty:
+ operating_cost = flt(d.cost_per_unit) * flt(self.quantity)
+ base_operating_cost = flt(d.base_cost_per_unit) * flt(self.quantity)
- self.operating_cost += flt(operating_cost)
- self.base_operating_cost += flt(base_operating_cost)
+ self.operating_cost += flt(operating_cost)
+ self.base_operating_cost += flt(base_operating_cost)
+
+ elif self.get("fg_based_operating_cost"):
+ total_operating_cost = flt(self.get("quantity")) * flt(
+ self.get("operating_cost_per_bom_quantity")
+ )
+ self.operating_cost = total_operating_cost
+ self.base_operating_cost = flt(total_operating_cost * self.conversion_rate, 2)
def update_rate_and_time(self, row, update_hour_rate=False):
if not row.hour_rate or update_hour_rate:
@@ -866,36 +886,19 @@ class BOM(WebsiteGenerator):
"""Get a complete tree representation preserving order of child items."""
return BOMTree(self.name)
+ def set_process_loss_qty(self):
+ if self.process_loss_percentage:
+ self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100
+
def validate_scrap_items(self):
- for item in self.scrap_items:
- msg = ""
- if item.item_code == self.item and not item.is_process_loss:
- msg = _(
- "Scrap/Loss Item: {0} should have Is Process Loss checked as it is the same as the item to be manufactured or repacked."
- ).format(frappe.bold(item.item_code))
- elif item.item_code != self.item and item.is_process_loss:
- msg = _(
- "Scrap/Loss Item: {0} should not have Is Process Loss checked as it is different from the item to be manufactured or repacked"
- ).format(frappe.bold(item.item_code))
+ must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number")
- must_be_whole_number = frappe.get_value("UOM", item.stock_uom, "must_be_whole_number")
- if item.is_process_loss and must_be_whole_number:
- msg = _(
- "Item: {0} with Stock UOM: {1} cannot be a Scrap/Loss Item as {1} is a whole UOM."
- ).format(frappe.bold(item.item_code), frappe.bold(item.stock_uom))
+ if self.process_loss_percentage and self.process_loss_percentage > 100:
+ frappe.throw(_("Process Loss Percentage cannot be greater than 100"))
- if item.is_process_loss and (item.stock_qty >= self.quantity):
- msg = _("Scrap/Loss Item: {0} should have Qty less than finished goods Quantity.").format(
- frappe.bold(item.item_code)
- )
-
- if item.is_process_loss and (item.rate > 0):
- msg = _(
- "Scrap/Loss Item: {0} should have Rate set to 0 because Is Process Loss is checked."
- ).format(frappe.bold(item.item_code))
-
- if msg:
- frappe.throw(msg, title=_("Note"))
+ if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0:
+ msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number."
+ frappe.throw(msg, title=_("Invalid Process Loss Configuration"))
def get_bom_item_rate(args, bom_doc):
@@ -1019,7 +1022,6 @@ def get_bom_items_as_dict(
where
bom_item.docstatus < 2
and bom.name = %(bom)s
- and ifnull(item.has_variants, 0) = 0
and item.is_stock_item in (1, {is_stock_item})
{where_conditions}
group by item_code, stock_uom
@@ -1044,7 +1046,7 @@ def get_bom_items_as_dict(
query = query.format(
table="BOM Scrap Item",
where_conditions="",
- select_columns=", item.description, is_process_loss",
+ select_columns=", item.description",
is_stock_item=is_stock_item,
qty_field="stock_qty",
)
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index a190cc7430..d60feb2b39 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -9,7 +9,10 @@ import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
-from erpnext.controllers.tests.test_subcontracting_controller import set_backflush_based_on
+from erpnext.controllers.tests.test_subcontracting_controller import (
+ make_stock_in_entry,
+ set_backflush_based_on,
+)
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
update_cost_in_all_boms_in_test,
@@ -199,6 +202,33 @@ class TestBOM(FrappeTestCase):
self.assertEqual(bom.items[0].rate, 20)
+ def test_bom_cost_with_fg_based_operating_cost(self):
+ bom = frappe.copy_doc(test_records[4])
+ bom.insert()
+
+ raw_material_cost = 0.0
+ op_cost = 0.0
+
+ op_cost = bom.quantity * bom.operating_cost_per_bom_quantity
+
+ for row in bom.items:
+ raw_material_cost += row.amount
+
+ base_raw_material_cost = raw_material_cost * flt(
+ bom.conversion_rate, bom.precision("conversion_rate")
+ )
+ base_op_cost = op_cost * flt(bom.conversion_rate, bom.precision("conversion_rate"))
+
+ # test amounts in selected currency, almostEqual checks for 7 digits by default
+ self.assertAlmostEqual(bom.operating_cost, op_cost)
+ self.assertAlmostEqual(bom.raw_material_cost, raw_material_cost)
+ self.assertAlmostEqual(bom.total_cost, raw_material_cost + op_cost)
+
+ # test amounts in selected currency
+ self.assertAlmostEqual(bom.base_operating_cost, base_op_cost)
+ self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost)
+ self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost)
+
def test_subcontractor_sourced_item(self):
item_code = "_Test Subcontracted FG Item 1"
set_backflush_based_on("Material Transferred for Subcontract")
@@ -381,36 +411,16 @@ class TestBOM(FrappeTestCase):
def test_bom_with_process_loss_item(self):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
- if not frappe.db.exists("BOM", f"BOM-{fg_item_non_whole.item_code}-001"):
- bom_doc = create_bom_with_process_loss_item(
- fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, fg_qty=1
- )
- bom_doc.submit()
-
bom_doc = create_bom_with_process_loss_item(
- fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0
+ fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0, process_loss_percentage=110
)
- # PL Item qty can't be >= FG Item qty
+ # PL can't be > 100
self.assertRaises(frappe.ValidationError, bom_doc.submit)
- bom_doc = create_bom_with_process_loss_item(
- fg_item_non_whole, bom_item, scrap_qty=1, scrap_rate=100
- )
- # PL Item rate has to be 0
- self.assertRaises(frappe.ValidationError, bom_doc.submit)
-
- bom_doc = create_bom_with_process_loss_item(
- fg_item_whole, bom_item, scrap_qty=0.25, scrap_rate=0
- )
+ bom_doc = create_bom_with_process_loss_item(fg_item_whole, bom_item, process_loss_percentage=20)
# Items with whole UOMs can't be PL Items
self.assertRaises(frappe.ValidationError, bom_doc.submit)
- bom_doc = create_bom_with_process_loss_item(
- fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, is_process_loss=0
- )
- # FG Items in Scrap/Loss Table should have Is Process Loss set
- self.assertRaises(frappe.ValidationError, bom_doc.submit)
-
def test_bom_item_query(self):
query = partial(
item_query,
@@ -611,6 +621,56 @@ class TestBOM(FrappeTestCase):
bom.reload()
self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name)
+ def test_exploded_items_rate(self):
+ rm_item = make_item(
+ properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89}
+ ).name
+ fg_item = make_item(properties={"is_stock_item": 1}).name
+
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+
+ bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True)
+
+ bom.rm_cost_as_per = "Last Purchase Rate"
+ bom.save()
+ self.assertEqual(bom.items[0].base_rate, 89)
+ self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate)
+
+ bom.rm_cost_as_per = "Price List"
+ bom.save()
+ self.assertEqual(bom.items[0].base_rate, 0.0)
+ self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate)
+
+ bom.rm_cost_as_per = "Valuation Rate"
+ bom.save()
+ self.assertEqual(bom.items[0].base_rate, 99)
+ self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate)
+
+ bom.submit()
+ self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate)
+
+ def test_bom_cost_update_flag(self):
+ rm_item = make_item(
+ properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89}
+ ).name
+ fg_item = make_item(properties={"is_stock_item": 1}).name
+
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+
+ bom = make_bom(item=fg_item, raw_materials=[rm_item])
+
+ create_stock_reconciliation(
+ item_code=rm_item, warehouse="_Test Warehouse - _TC", qty=100, rate=600
+ )
+
+ bom.load_from_db()
+ bom.update_cost()
+ self.assertTrue(bom.flags.cost_updated)
+
+ bom.load_from_db()
+ bom.update_cost()
+ self.assertFalse(bom.flags.cost_updated)
+
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
@@ -691,7 +751,7 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
def create_bom_with_process_loss_item(
- fg_item, bom_item, scrap_qty, scrap_rate, fg_qty=2, is_process_loss=1
+ fg_item, bom_item, scrap_qty=0, scrap_rate=0, fg_qty=2, process_loss_percentage=0
):
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item.item_code
@@ -706,19 +766,22 @@ def create_bom_with_process_loss_item(
"rate": 100.0,
},
)
- bom_doc.append(
- "scrap_items",
- {
- "item_code": fg_item.item_code,
- "qty": scrap_qty,
- "stock_qty": scrap_qty,
- "uom": fg_item.stock_uom,
- "stock_uom": fg_item.stock_uom,
- "rate": scrap_rate,
- "is_process_loss": is_process_loss,
- },
- )
+
+ if scrap_qty:
+ bom_doc.append(
+ "scrap_items",
+ {
+ "item_code": fg_item.item_code,
+ "qty": scrap_qty,
+ "stock_qty": scrap_qty,
+ "uom": fg_item.stock_uom,
+ "stock_uom": fg_item.stock_uom,
+ "rate": scrap_rate,
+ },
+ )
+
bom_doc.currency = "INR"
+ bom_doc.process_loss_percentage = process_loss_percentage
return bom_doc
diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json
index 507d319b51..e9cbdfe638 100644
--- a/erpnext/manufacturing/doctype/bom/test_records.json
+++ b/erpnext/manufacturing/doctype/bom/test_records.json
@@ -162,5 +162,31 @@
"item": "_Test Variant Item",
"quantity": 1.0,
"with_operations": 1
+ },
+ {
+ "items": [
+ {
+ "amount": 5000.0,
+ "doctype": "BOM Item",
+ "item_code": "_Test Item",
+ "parentfield": "items",
+ "qty": 2.0,
+ "rate": 3000.0,
+ "uom": "_Test UOM",
+ "stock_uom": "_Test UOM",
+ "source_warehouse": "_Test Warehouse - _TC",
+ "include_item_in_manufacturing": 1
+ }
+ ],
+ "docstatus": 1,
+ "doctype": "BOM",
+ "is_active": 1,
+ "is_default": 1,
+ "currency": "USD",
+ "item": "_Test Variant Item",
+ "quantity": 1.0,
+ "with_operations": 0,
+ "fg_based_operating_cost": 1,
+ "operating_cost_per_bom_quantity": 140
}
]
diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json
index 0a8ae7b4a7..c5266119dc 100644
--- a/erpnext/manufacturing/doctype/bom_item/bom_item.json
+++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json
@@ -184,6 +184,7 @@
"in_list_view": 1,
"label": "Rate",
"options": "currency",
+ "read_only": 1,
"reqd": 1
},
{
@@ -288,7 +289,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-05-19 02:32:43.785470",
+ "modified": "2022-07-28 10:20:51.559010",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",
diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
index b965a435bf..5a734d8684 100644
--- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
+++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
@@ -9,6 +9,7 @@
"sequence_id",
"operation",
"col_break1",
+ "workstation_type",
"workstation",
"time_in_mins",
"fixed_time",
@@ -40,9 +41,9 @@
"reqd": 1
},
{
+ "depends_on": "eval:!doc.workstation_type",
"fieldname": "workstation",
"fieldtype": "Link",
- "in_list_view": 1,
"label": "Workstation",
"oldfieldname": "workstation",
"oldfieldtype": "Link",
@@ -180,13 +181,20 @@
"fieldname": "set_cost_based_on_bom_qty",
"fieldtype": "Check",
"label": "Set Operating Cost Based On BOM Quantity"
+ },
+ {
+ "fieldname": "workstation_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Workstation Type",
+ "options": "Workstation Type"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-04-08 01:18:33.547481",
+ "modified": "2022-11-04 17:17:16.986941",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",
diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json
index 7018082e40..b2ef19b20f 100644
--- a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json
+++ b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json
@@ -8,7 +8,6 @@
"item_code",
"column_break_2",
"item_name",
- "is_process_loss",
"quantity_and_rate",
"stock_qty",
"rate",
@@ -89,17 +88,11 @@
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
- },
- {
- "default": "0",
- "fieldname": "is_process_loss",
- "fieldtype": "Check",
- "label": "Is Process Loss"
}
],
"istable": 1,
"links": [],
- "modified": "2021-06-22 16:46:12.153311",
+ "modified": "2023-01-03 14:19:28.460965",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Scrap Item",
@@ -108,5 +101,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index b6646b19f6..619e6bd1a0 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -33,6 +33,11 @@ frappe.ui.form.on('Job Card', {
return;
}
+ let has_stock_entry = frm.doc.__onload &&
+ frm.doc.__onload.has_stock_entry ? true : false;
+
+ frm.toggle_enable("for_quantity", !has_stock_entry);
+
if (!frm.is_new() && has_items && frm.doc.docstatus < 2) {
let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json
index 5a071f1da6..85061113ce 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.json
+++ b/erpnext/manufacturing/doctype/job_card/job_card.json
@@ -27,11 +27,14 @@
"operation",
"operation_row_number",
"column_break_18",
+ "workstation_type",
"workstation",
"employee",
"section_break_21",
"sub_operations",
"timing_detail",
+ "expected_start_date",
+ "expected_end_date",
"time_logs",
"section_break_13",
"total_completed_qty",
@@ -416,11 +419,27 @@
"fieldtype": "Link",
"label": "Quality Inspection Template",
"options": "Quality Inspection Template"
+ },
+ {
+ "fieldname": "workstation_type",
+ "fieldtype": "Link",
+ "label": "Workstation Type",
+ "options": "Workstation Type"
+ },
+ {
+ "fieldname": "expected_start_date",
+ "fieldtype": "Datetime",
+ "label": "Expected Start Date"
+ },
+ {
+ "fieldname": "expected_end_date",
+ "fieldtype": "Datetime",
+ "label": "Expected End Date"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2021-11-24 19:17:40.879235",
+ "modified": "2022-11-09 15:02:44.490731",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",
@@ -475,6 +494,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "operation",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index ed45106634..3133628cbf 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -2,11 +2,14 @@
# For license information, please see license.txt
import datetime
import json
+from typing import Optional
import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
+from frappe.query_builder import Criterion
+from frappe.query_builder.functions import IfNull, Max, Min
from frappe.utils import (
add_days,
add_to_date,
@@ -24,6 +27,7 @@ from frappe.utils import (
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
+from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations
class OverlapError(frappe.ValidationError):
@@ -53,6 +57,13 @@ class JobCard(Document):
)
self.set_onload("job_card_excess_transfer", excess_transfer)
self.set_onload("work_order_closed", self.is_work_order_closed())
+ self.set_onload("has_stock_entry", self.has_stock_entry())
+
+ def has_stock_entry(self):
+ return frappe.db.exists("Stock Entry", {"job_card": self.name, "docstatus": ["!=", 2]})
+
+ def before_validate(self):
+ self.set_wip_warehouse()
def validate(self):
self.validate_time_logs()
@@ -109,49 +120,66 @@ class JobCard(Document):
def get_overlap_for(self, args, check_next_available_slot=False):
production_capacity = 1
+ jc = frappe.qb.DocType("Job Card")
+ jctl = frappe.qb.DocType("Job Card Time Log")
+
+ time_conditions = [
+ ((jctl.from_time < args.from_time) & (jctl.to_time > args.from_time)),
+ ((jctl.from_time < args.to_time) & (jctl.to_time > args.to_time)),
+ ((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)),
+ ]
+
+ if check_next_available_slot:
+ time_conditions.append(((jctl.from_time >= args.from_time) & (jctl.to_time >= args.to_time)))
+
+ query = (
+ frappe.qb.from_(jctl)
+ .from_(jc)
+ .select(jc.name.as_("name"), jctl.to_time, jc.workstation, jc.workstation_type)
+ .where(
+ (jctl.parent == jc.name)
+ & (Criterion.any(time_conditions))
+ & (jctl.name != f"{args.name or 'No Name'}")
+ & (jc.name != f"{args.parent or 'No Name'}")
+ & (jc.docstatus < 2)
+ )
+ .orderby(jctl.to_time, order=frappe.qb.desc)
+ )
+
+ if self.workstation_type:
+ query = query.where(jc.workstation_type == self.workstation_type)
+
if self.workstation:
production_capacity = (
frappe.get_cached_value("Workstation", self.workstation, "production_capacity") or 1
)
- validate_overlap_for = " and jc.workstation = %(workstation)s "
+ query = query.where(jc.workstation == self.workstation)
if args.get("employee"):
# override capacity for employee
production_capacity = 1
- validate_overlap_for = " and jctl.employee = %(employee)s "
+ query = query.where(jctl.employee == args.get("employee"))
- extra_cond = ""
- if check_next_available_slot:
- extra_cond = " or (%(from_time)s <= jctl.from_time and %(to_time)s <= jctl.to_time)"
-
- existing = frappe.db.sql(
- """select jc.name as name, jctl.to_time from
- `tabJob Card Time Log` jctl, `tabJob Card` jc where jctl.parent = jc.name and
- (
- (%(from_time)s > jctl.from_time and %(from_time)s < jctl.to_time) or
- (%(to_time)s > jctl.from_time and %(to_time)s < jctl.to_time) or
- (%(from_time)s <= jctl.from_time and %(to_time)s >= jctl.to_time) {0}
- )
- and jctl.name != %(name)s and jc.name != %(parent)s and jc.docstatus < 2 {1}
- order by jctl.to_time desc limit 1""".format(
- extra_cond, validate_overlap_for
- ),
- {
- "from_time": args.from_time,
- "to_time": args.to_time,
- "name": args.name or "No Name",
- "parent": args.parent or "No Name",
- "employee": args.get("employee"),
- "workstation": self.workstation,
- },
- as_dict=True,
- )
+ existing = query.run(as_dict=True)
if existing and production_capacity > len(existing):
return
+ if self.workstation_type:
+ if workstation := self.get_workstation_based_on_available_slot(existing):
+ self.workstation = workstation
+ return None
+
return existing[0] if existing else None
+ def get_workstation_based_on_available_slot(self, existing) -> Optional[str]:
+ workstations = get_workstations(self.workstation_type)
+ if workstations:
+ busy_workstations = [row.workstation for row in existing]
+ for workstation in workstations:
+ if workstation not in busy_workstations:
+ return workstation
+
def schedule_time_logs(self, row):
row.remaining_time_in_mins = row.time_in_mins
while row.remaining_time_in_mins > 0:
@@ -164,6 +192,9 @@ class JobCard(Document):
# get the last record based on the to time from the job card
data = self.get_overlap_for(args, check_next_available_slot=True)
if data:
+ if not self.workstation:
+ self.workstation = data.workstation
+
row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations())
def check_workstation_time(self, row):
@@ -485,18 +516,21 @@ class JobCard(Document):
)
def update_work_order_data(self, for_quantity, time_in_mins, wo):
- time_data = frappe.db.sql(
- """
- SELECT
- min(from_time) as start_time, max(to_time) as end_time
- FROM `tabJob Card` jc, `tabJob Card Time Log` jctl
- WHERE
- jctl.parent = jc.name and jc.work_order = %s and jc.operation_id = %s
- and jc.docstatus = 1 and IFNULL(jc.is_corrective_job_card, 0) = 0
- """,
- (self.work_order, self.operation_id),
- as_dict=1,
- )
+ jc = frappe.qb.DocType("Job Card")
+ jctl = frappe.qb.DocType("Job Card Time Log")
+
+ time_data = (
+ frappe.qb.from_(jc)
+ .from_(jctl)
+ .select(Min(jctl.from_time).as_("start_time"), Max(jctl.to_time).as_("end_time"))
+ .where(
+ (jctl.parent == jc.name)
+ & (jc.work_order == self.work_order)
+ & (jc.operation_id == self.operation_id)
+ & (jc.docstatus == 1)
+ & (IfNull(jc.is_corrective_job_card, 0) == 0)
+ )
+ ).run(as_dict=True)
for data in wo.operations:
if data.get("name") == self.operation_id:
@@ -639,6 +673,12 @@ class JobCard(Document):
if update_status:
self.db_set("status", self.status)
+ def set_wip_warehouse(self):
+ if not self.wip_warehouse:
+ self.wip_warehouse = frappe.db.get_single_value(
+ "Manufacturing Settings", "default_wip_warehouse"
+ )
+
def validate_operation_id(self):
if (
self.get("operation_id")
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index ac7114138c..4d2dab73e3 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -136,6 +136,45 @@ class TestJobCard(FrappeTestCase):
)
self.assertRaises(OverlapError, jc2.save)
+ def test_job_card_overlap_with_capacity(self):
+ wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
+
+ workstation = make_workstation(workstation_name=random_string(5)).name
+ frappe.db.set_value("Workstation", workstation, "production_capacity", 1)
+
+ jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+ jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
+
+ jc1.workstation = workstation
+ jc1.append(
+ "time_logs",
+ {"from_time": "2021-01-01 00:00:00", "to_time": "2021-01-01 08:00:00", "completed_qty": 1},
+ )
+ jc1.save()
+
+ jc2.workstation = workstation
+
+ # add a new entry in same time slice
+ jc2.append(
+ "time_logs",
+ {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 1},
+ )
+ self.assertRaises(OverlapError, jc2.save)
+
+ frappe.db.set_value("Workstation", workstation, "production_capacity", 2)
+ jc2.load_from_db()
+
+ jc2.workstation = workstation
+
+ # add a new entry in same time slice
+ jc2.append(
+ "time_logs",
+ {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 1},
+ )
+
+ jc2.save()
+ self.assertTrue(jc2.name)
+
def test_job_card_multiple_materials_transfer(self):
"Test transferring RMs separately against Job Card with multiple RMs."
self.transfer_material_against = "Job Card"
diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
index 27d7c4175e..8c61d545b8 100644
--- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
+++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json
@@ -47,7 +47,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
- "label": "Warehouse",
+ "label": "For Warehouse",
"options": "Warehouse",
"reqd": 1
},
@@ -173,7 +173,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-08-23 18:17:58.400462",
+ "modified": "2022-11-26 14:59:25.879631",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",
@@ -182,5 +182,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js
index 59ddf1f0c5..62715e6565 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.js
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js
@@ -3,13 +3,13 @@
frappe.ui.form.on('Production Plan', {
- before_save: function(frm) {
+ before_save(frm) {
// preserve temporary names on production plan item to re-link sub-assembly items
frm.doc.po_items.forEach(item => {
item.temporary_name = item.name;
});
},
- setup: function(frm) {
+ setup(frm) {
frm.custom_make_buttons = {
'Work Order': 'Work Order / Subcontract PO',
'Material Request': 'Material Request',
@@ -70,7 +70,7 @@ frappe.ui.form.on('Production Plan', {
}
},
- refresh: function(frm) {
+ refresh(frm) {
if (frm.doc.docstatus === 1) {
frm.trigger("show_progress");
@@ -158,7 +158,7 @@ frappe.ui.form.on('Production Plan', {
set_field_options("projected_qty_formula", projected_qty_formula);
},
- close_open_production_plan: (frm, close=false) => {
+ close_open_production_plan(frm, close=false) {
frappe.call({
method: "set_status",
freeze: true,
@@ -170,7 +170,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- make_work_order: function(frm) {
+ make_work_order(frm) {
frappe.call({
method: "make_work_order",
freeze: true,
@@ -181,7 +181,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- make_material_request: function(frm) {
+ make_material_request(frm) {
frappe.confirm(__("Do you want to submit the material request"),
function() {
@@ -193,7 +193,7 @@ frappe.ui.form.on('Production Plan', {
);
},
- create_material_request: function(frm, submit) {
+ create_material_request(frm, submit) {
frm.doc.submit_material_request = submit;
frappe.call({
@@ -206,7 +206,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- get_sales_orders: function(frm) {
+ get_sales_orders(frm) {
frappe.call({
method: "get_open_sales_orders",
doc: frm.doc,
@@ -216,7 +216,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- get_material_request: function(frm) {
+ get_material_request(frm) {
frappe.call({
method: "get_pending_material_requests",
doc: frm.doc,
@@ -226,7 +226,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- get_items: function (frm) {
+ get_items(frm) {
frm.clear_table('prod_plan_references');
frappe.call({
@@ -238,7 +238,7 @@ frappe.ui.form.on('Production Plan', {
}
});
},
- combine_items: function (frm) {
+ combine_items(frm) {
frm.clear_table("prod_plan_references");
frappe.call({
@@ -254,14 +254,14 @@ frappe.ui.form.on('Production Plan', {
});
},
- combine_sub_items: (frm) => {
+ combine_sub_items(frm) {
if (frm.doc.sub_assembly_items.length > 0) {
frm.clear_table("sub_assembly_items");
frm.trigger("get_sub_assembly_items");
}
},
- get_sub_assembly_items: function(frm) {
+ get_sub_assembly_items(frm) {
frm.dirty();
frappe.call({
@@ -274,9 +274,25 @@ frappe.ui.form.on('Production Plan', {
});
},
- get_items_for_mr: function(frm) {
+ toggle_for_warehouse(frm) {
+ frm.toggle_reqd("for_warehouse", true);
+ },
+
+ get_items_for_mr(frm) {
if (!frm.doc.for_warehouse) {
- frappe.throw(__("To make material requests, 'Make Material Request for Warehouse' field is mandatory"));
+ frm.trigger("toggle_for_warehouse");
+ frappe.throw(__("Select the Warehouse"));
+ }
+
+ frm.events.get_items_for_material_requests(frm, [{
+ warehouse: frm.doc.for_warehouse
+ }]);
+ },
+
+ transfer_materials(frm) {
+ if (!frm.doc.for_warehouse) {
+ frm.trigger("toggle_for_warehouse");
+ frappe.throw(__("Select the Warehouse"));
}
if (frm.doc.ignore_existing_ordered_qty) {
@@ -287,18 +303,10 @@ frappe.ui.form.on('Production Plan', {
title: title,
fields: [
{
- 'label': __('Target Warehouse'),
- 'fieldtype': 'Link',
- 'fieldname': 'target_warehouse',
- 'read_only': true,
- 'default': frm.doc.for_warehouse
- },
- {
- 'label': __('Source Warehouses (Optional)'),
+ 'label': __('Transfer From Warehouses'),
'fieldtype': 'Table MultiSelect',
'fieldname': 'warehouses',
'options': 'Production Plan Material Request Warehouse',
- 'description': __('If source warehouse selected then system will create the material request with type Material Transfer from Source to Target warehouse. If not selected then will create the material request with type Purchase for the target warehouse.'),
get_query: function () {
return {
filters: {
@@ -307,6 +315,13 @@ frappe.ui.form.on('Production Plan', {
};
},
},
+ {
+ 'label': __('For Warehouse'),
+ 'fieldtype': 'Link',
+ 'fieldname': 'target_warehouse',
+ 'read_only': true,
+ 'default': frm.doc.for_warehouse
+ }
]
});
@@ -320,8 +335,8 @@ frappe.ui.form.on('Production Plan', {
}
},
- get_items_for_material_requests: function(frm, warehouses) {
- const set_fields = ['actual_qty', 'item_code','item_name', 'description', 'uom', 'from_warehouse',
+ get_items_for_material_requests(frm, warehouses) {
+ let set_fields = ['actual_qty', 'item_code','item_name', 'description', 'uom', 'from_warehouse',
'min_order_qty', 'required_bom_qty', 'quantity', 'sales_order', 'warehouse', 'projected_qty', 'ordered_qty',
'reserved_qty_for_production', 'material_request_type'];
@@ -335,13 +350,13 @@ frappe.ui.form.on('Production Plan', {
callback: function(r) {
if(r.message) {
frm.set_value('mr_items', []);
- $.each(r.message, function(i, d) {
- var item = frm.add_child('mr_items');
- for (let key in d) {
- if (d[key] && in_list(set_fields, key)) {
- item[key] = d[key];
+ r.message.forEach(row => {
+ let d = frm.add_child('mr_items');
+ set_fields.forEach(field => {
+ if (row[field]) {
+ d[field] = row[field];
}
- }
+ });
});
}
refresh_field('mr_items');
@@ -349,13 +364,7 @@ frappe.ui.form.on('Production Plan', {
});
},
- for_warehouse: function(frm) {
- if (frm.doc.mr_items && frm.doc.for_warehouse) {
- frm.trigger("get_items_for_mr");
- }
- },
-
- download_materials_required: function(frm) {
+ download_materials_required(frm) {
const fields = [{
fieldname: 'warehouses',
fieldtype: 'Table MultiSelect',
@@ -381,7 +390,7 @@ frappe.ui.form.on('Production Plan', {
}, __('Select Warehouses to get Stock for Materials Planning'), __('Get Stock'));
},
- show_progress: function(frm) {
+ show_progress(frm) {
var bars = [];
var message = '';
var title = '';
@@ -416,7 +425,7 @@ frappe.ui.form.on('Production Plan', {
});
frappe.ui.form.on("Production Plan Item", {
- item_code: function(frm, cdt, cdn) {
+ item_code(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.item_code) {
frappe.call({
@@ -435,7 +444,7 @@ frappe.ui.form.on("Production Plan Item", {
});
frappe.ui.form.on("Material Request Plan Item", {
- warehouse: function(frm, cdt, cdn) {
+ warehouse(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.warehouse && row.item_code && frm.doc.company) {
frappe.call({
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json
index 85f98430cd..2624daa41e 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.json
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json
@@ -38,6 +38,8 @@
"get_sub_assembly_items",
"combine_sub_items",
"sub_assembly_items",
+ "download_materials_request_plan_section_section",
+ "download_materials_required",
"material_request_planning",
"include_non_stock_items",
"include_subcontracted_items",
@@ -45,8 +47,8 @@
"ignore_existing_ordered_qty",
"column_break_25",
"for_warehouse",
- "download_materials_required",
"get_items_for_mr",
+ "transfer_materials",
"section_break_27",
"mr_items",
"other_details",
@@ -206,7 +208,7 @@
{
"fieldname": "material_request_planning",
"fieldtype": "Section Break",
- "label": "Material Requirement Planning"
+ "label": "Material Request Planning"
},
{
"default": "1",
@@ -235,12 +237,12 @@
"depends_on": "eval:!doc.__islocal",
"fieldname": "download_materials_required",
"fieldtype": "Button",
- "label": "Download Required Materials"
+ "label": "Download Materials Request Plan"
},
{
"fieldname": "get_items_for_mr",
"fieldtype": "Button",
- "label": "Get Raw Materials For Production"
+ "label": "Get Raw Materials for Purchase"
},
{
"fieldname": "section_break_27",
@@ -304,7 +306,7 @@
{
"fieldname": "for_warehouse",
"fieldtype": "Link",
- "label": "Make Material Request for Warehouse",
+ "label": "Raw Materials Warehouse",
"options": "Warehouse"
},
{
@@ -378,13 +380,24 @@
"fieldname": "combine_sub_items",
"fieldtype": "Check",
"label": "Consolidate Sub Assembly Items"
+ },
+ {
+ "fieldname": "transfer_materials",
+ "fieldtype": "Button",
+ "label": "Get Raw Materials for Transfer"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "download_materials_request_plan_section_section",
+ "fieldtype": "Section Break",
+ "label": "Download Materials Request Plan Section"
}
],
"icon": "fa fa-calendar",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-25 09:15:25.017664",
+ "modified": "2022-11-26 14:51:08.774372",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan",
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 70ccb78278..0cc0f80cf1 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -8,6 +8,7 @@ import json
import frappe
from frappe import _, msgprint
from frappe.model.document import Document
+from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
ceil,
@@ -20,11 +21,13 @@ from frappe.utils import (
nowdate,
)
from frappe.utils.csvutils import build_csv_response
+from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
+from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -100,39 +103,46 @@ class ProductionPlan(Document):
@frappe.whitelist()
def get_pending_material_requests(self):
"""Pull Material Requests that are pending based on criteria selected"""
- mr_filter = item_filter = ""
+
+ bom = frappe.qb.DocType("BOM")
+ mr = frappe.qb.DocType("Material Request")
+ mr_item = frappe.qb.DocType("Material Request Item")
+
+ pending_mr_query = (
+ frappe.qb.from_(mr)
+ .from_(mr_item)
+ .select(mr.name, mr.transaction_date)
+ .distinct()
+ .where(
+ (mr_item.parent == mr.name)
+ & (mr.material_request_type == "Manufacture")
+ & (mr.docstatus == 1)
+ & (mr.status != "Stopped")
+ & (mr.company == self.company)
+ & (mr_item.qty > IfNull(mr_item.ordered_qty, 0))
+ & (
+ ExistsCriterion(
+ frappe.qb.from_(bom)
+ .select(bom.name)
+ .where((bom.item == mr_item.item_code) & (bom.is_active == 1))
+ )
+ )
+ )
+ )
+
if self.from_date:
- mr_filter += " and mr.transaction_date >= %(from_date)s"
+ pending_mr_query = pending_mr_query.where(mr.transaction_date >= self.from_date)
+
if self.to_date:
- mr_filter += " and mr.transaction_date <= %(to_date)s"
+ pending_mr_query = pending_mr_query.where(mr.transaction_date <= self.to_date)
+
if self.warehouse:
- mr_filter += " and mr_item.warehouse = %(warehouse)s"
+ pending_mr_query = pending_mr_query.where(mr_item.warehouse == self.warehouse)
if self.item_code:
- item_filter += " and mr_item.item_code = %(item)s"
+ pending_mr_query = pending_mr_query.where(mr_item.item_code == self.item_code)
- pending_mr = frappe.db.sql(
- """
- select distinct mr.name, mr.transaction_date
- from `tabMaterial Request` mr, `tabMaterial Request Item` mr_item
- where mr_item.parent = mr.name
- and mr.material_request_type = "Manufacture"
- and mr.docstatus = 1 and mr.status != "Stopped" and mr.company = %(company)s
- and mr_item.qty > ifnull(mr_item.ordered_qty,0) {0} {1}
- and (exists (select name from `tabBOM` bom where bom.item=mr_item.item_code
- and bom.is_active = 1))
- """.format(
- mr_filter, item_filter
- ),
- {
- "from_date": self.from_date,
- "to_date": self.to_date,
- "warehouse": self.warehouse,
- "item": self.item_code,
- "company": self.company,
- },
- as_dict=1,
- )
+ pending_mr = pending_mr_query.run(as_dict=True)
self.add_mr_in_table(pending_mr)
@@ -160,16 +170,17 @@ class ProductionPlan(Document):
so_mr_list = [d.get(field) for d in self.get(table) if d.get(field)]
return so_mr_list
- def get_bom_item(self):
+ def get_bom_item_condition(self):
"""Check if Item or if its Template has a BOM."""
- bom_item = None
+ bom_item_condition = None
has_bom = frappe.db.exists({"doctype": "BOM", "item": self.item_code, "docstatus": 1})
+
if not has_bom:
+ bom = frappe.qb.DocType("BOM")
template_item = frappe.db.get_value("Item", self.item_code, ["variant_of"])
- bom_item = (
- "bom.item = {0}".format(frappe.db.escape(template_item)) if template_item else bom_item
- )
- return bom_item
+ bom_item_condition = bom.item == template_item or None
+
+ return bom_item_condition
def get_so_items(self):
# Check for empty table or empty rows
@@ -178,46 +189,75 @@ class ProductionPlan(Document):
so_list = self.get_so_mr_list("sales_order", "sales_orders")
- item_condition = ""
- bom_item = "bom.item = so_item.item_code"
- if self.item_code and frappe.db.exists("Item", self.item_code):
- bom_item = self.get_bom_item() or bom_item
- item_condition = " and so_item.item_code = {0}".format(frappe.db.escape(self.item_code))
+ bom = frappe.qb.DocType("BOM")
+ so_item = frappe.qb.DocType("Sales Order Item")
- items = frappe.db.sql(
- """
- select
- distinct parent, item_code, warehouse,
- (qty - work_order_qty) * conversion_factor as pending_qty,
- description, name
- from
- `tabSales Order Item` so_item
- where
- parent in (%s) and docstatus = 1 and qty > work_order_qty
- and exists (select name from `tabBOM` bom where %s
- and bom.is_active = 1) %s"""
- % (", ".join(["%s"] * len(so_list)), bom_item, item_condition),
- tuple(so_list),
- as_dict=1,
+ items_subquery = frappe.qb.from_(bom).select(bom.name).where(bom.is_active == 1)
+ items_query = (
+ frappe.qb.from_(so_item)
+ .select(
+ so_item.parent,
+ so_item.item_code,
+ so_item.warehouse,
+ (
+ (so_item.qty - so_item.work_order_qty - so_item.delivered_qty) * so_item.conversion_factor
+ ).as_("pending_qty"),
+ so_item.description,
+ so_item.name,
+ )
+ .distinct()
+ .where(
+ (so_item.parent.isin(so_list))
+ & (so_item.docstatus == 1)
+ & (so_item.qty > so_item.work_order_qty)
+ )
+ )
+
+ if self.item_code and frappe.db.exists("Item", self.item_code):
+ items_query = items_query.where(so_item.item_code == self.item_code)
+ items_subquery = items_subquery.where(
+ self.get_bom_item_condition() or bom.item == so_item.item_code
+ )
+
+ items_query = items_query.where(ExistsCriterion(items_subquery))
+
+ items = items_query.run(as_dict=True)
+
+ pi = frappe.qb.DocType("Packed Item")
+
+ packed_items_query = (
+ frappe.qb.from_(so_item)
+ .from_(pi)
+ .select(
+ pi.parent,
+ pi.item_code,
+ pi.warehouse.as_("warehouse"),
+ (((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty).as_("pending_qty"),
+ pi.parent_item,
+ pi.description,
+ so_item.name,
+ )
+ .distinct()
+ .where(
+ (so_item.parent == pi.parent)
+ & (so_item.docstatus == 1)
+ & (pi.parent_item == so_item.item_code)
+ & (so_item.parent.isin(so_list))
+ & (so_item.qty > so_item.work_order_qty)
+ & (
+ ExistsCriterion(
+ frappe.qb.from_(bom)
+ .select(bom.name)
+ .where((bom.item == pi.item_code) & (bom.is_active == 1))
+ )
+ )
+ )
)
if self.item_code:
- item_condition = " and so_item.item_code = {0}".format(frappe.db.escape(self.item_code))
+ packed_items_query = packed_items_query.where(so_item.item_code == self.item_code)
- packed_items = frappe.db.sql(
- """select distinct pi.parent, pi.item_code, pi.warehouse as warehouse,
- (((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty)
- as pending_qty, pi.parent_item, pi.description, so_item.name
- from `tabSales Order Item` so_item, `tabPacked Item` pi
- where so_item.parent = pi.parent and so_item.docstatus = 1
- and pi.parent_item = so_item.item_code
- and so_item.parent in (%s) and so_item.qty > so_item.work_order_qty
- and exists (select name from `tabBOM` bom where bom.item=pi.item_code
- and bom.is_active = 1) %s"""
- % (", ".join(["%s"] * len(so_list)), item_condition),
- tuple(so_list),
- as_dict=1,
- )
+ packed_items = packed_items_query.run(as_dict=True)
self.add_items(items + packed_items)
self.calculate_total_planned_qty()
@@ -233,28 +273,48 @@ class ProductionPlan(Document):
mr_list = self.get_so_mr_list("material_request", "material_requests")
- item_condition = ""
- if self.item_code:
- item_condition = " and mr_item.item_code ={0}".format(frappe.db.escape(self.item_code))
+ bom = frappe.qb.DocType("BOM")
+ mr_item = frappe.qb.DocType("Material Request Item")
- items = frappe.db.sql(
- """select distinct parent, name, item_code, warehouse, description,
- (qty - ordered_qty) * conversion_factor as pending_qty
- from `tabMaterial Request Item` mr_item
- where parent in (%s) and docstatus = 1 and qty > ordered_qty
- and exists (select name from `tabBOM` bom where bom.item=mr_item.item_code
- and bom.is_active = 1) %s"""
- % (", ".join(["%s"] * len(mr_list)), item_condition),
- tuple(mr_list),
- as_dict=1,
+ items_query = (
+ frappe.qb.from_(mr_item)
+ .select(
+ mr_item.parent,
+ mr_item.name,
+ mr_item.item_code,
+ mr_item.warehouse,
+ mr_item.description,
+ ((mr_item.qty - mr_item.ordered_qty) * mr_item.conversion_factor).as_("pending_qty"),
+ )
+ .distinct()
+ .where(
+ (mr_item.parent.isin(mr_list))
+ & (mr_item.docstatus == 1)
+ & (mr_item.qty > mr_item.ordered_qty)
+ & (
+ ExistsCriterion(
+ frappe.qb.from_(bom)
+ .select(bom.name)
+ .where((bom.item == mr_item.item_code) & (bom.is_active == 1))
+ )
+ )
+ )
)
+ if self.item_code:
+ items_query = items_query.where(mr_item.item_code == self.item_code)
+
+ items = items_query.run(as_dict=True)
+
self.add_items(items)
self.calculate_total_planned_qty()
def add_items(self, items):
refs = {}
for data in items:
+ if not data.pending_qty:
+ continue
+
item_details = get_item_details(data.item_code)
if self.combine_items:
if item_details.bom_no in refs:
@@ -461,6 +521,9 @@ class ProductionPlan(Document):
subcontracted_po.setdefault(row.supplier, []).append(row)
continue
+ if row.type_of_manufacturing == "Material Request":
+ continue
+
work_order_data = {
"wip_warehouse": default_warehouses.get("wip_warehouse"),
"fg_warehouse": default_warehouses.get("fg_warehouse"),
@@ -482,7 +545,6 @@ class ProductionPlan(Document):
"bom_no",
"stock_uom",
"bom_level",
- "production_plan_item",
"schedule_date",
]:
if row.get(field):
@@ -639,6 +701,9 @@ class ProductionPlan(Document):
sub_assembly_items_store = [] # temporary store to process all subassembly items
for row in self.po_items:
+ if not row.item_code:
+ frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
+
bom_data = []
get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty)
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
@@ -654,6 +719,8 @@ class ProductionPlan(Document):
row.idx = idx + 1
self.append("sub_assembly_items", row)
+ self.set_default_supplier_for_subcontracting_order()
+
def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
"Modify bom_data, set additional details."
for data in bom_data:
@@ -665,6 +732,32 @@ class ProductionPlan(Document):
"Subcontract" if data.is_sub_contracted_item else "In House"
)
+ def set_default_supplier_for_subcontracting_order(self):
+ items = [
+ d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
+ ]
+
+ if not items:
+ return
+
+ default_supplier = frappe._dict(
+ frappe.get_all(
+ "Item Default",
+ fields=["parent", "default_supplier"],
+ filters={"parent": ("in", items), "default_supplier": ("is", "set")},
+ as_list=1,
+ )
+ )
+
+ if not default_supplier:
+ return
+
+ for row in self.sub_assembly_items:
+ if row.type_of_manufacturing != "Subcontract":
+ continue
+
+ row.supplier = default_supplier.get(row.production_item)
+
def combine_subassembly_items(self, sub_assembly_items_store):
"Aggregate if same: Item, Warehouse, Inhouse/Outhouse Manu.g, BOM No."
key_wise_data = {}
@@ -789,29 +882,46 @@ def download_raw_materials(doc, warehouses=None):
def get_exploded_items(item_details, company, bom_no, include_non_stock_items, planned_qty=1):
- for d in frappe.db.sql(
- """select bei.item_code, item.default_bom as bom,
- ifnull(sum(bei.stock_qty/ifnull(bom.quantity, 1)), 0)*%s as qty, item.item_name,
- bei.description, bei.stock_uom, item.min_order_qty, bei.source_warehouse,
- item.default_material_request_type, item.min_order_qty, item_default.default_warehouse,
- item.purchase_uom, item_uom.conversion_factor, item.safety_stock
- from
- `tabBOM Explosion Item` bei
- JOIN `tabBOM` bom ON bom.name = bei.parent
- JOIN `tabItem` item ON item.name = bei.item_code
- LEFT JOIN `tabItem Default` item_default
- ON item_default.parent = item.name and item_default.company=%s
- LEFT JOIN `tabUOM Conversion Detail` item_uom
- ON item.name = item_uom.parent and item_uom.uom = item.purchase_uom
- where
- bei.docstatus < 2
- and bom.name=%s and item.is_stock_item in (1, {0})
- group by bei.item_code, bei.stock_uom""".format(
- 0 if include_non_stock_items else 1
- ),
- (planned_qty, company, bom_no),
- as_dict=1,
- ):
+ bei = frappe.qb.DocType("BOM Explosion Item")
+ bom = frappe.qb.DocType("BOM")
+ item = frappe.qb.DocType("Item")
+ item_default = frappe.qb.DocType("Item Default")
+ item_uom = frappe.qb.DocType("UOM Conversion Detail")
+
+ data = (
+ frappe.qb.from_(bei)
+ .join(bom)
+ .on(bom.name == bei.parent)
+ .join(item)
+ .on(item.name == bei.item_code)
+ .left_join(item_default)
+ .on((item_default.parent == item.name) & (item_default.company == company))
+ .left_join(item_uom)
+ .on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
+ .select(
+ (IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
+ item.item_name,
+ item.name.as_("item_code"),
+ bei.description,
+ bei.stock_uom,
+ item.min_order_qty,
+ bei.source_warehouse,
+ item.default_material_request_type,
+ item.min_order_qty,
+ item_default.default_warehouse,
+ item.purchase_uom,
+ item_uom.conversion_factor,
+ item.safety_stock,
+ )
+ .where(
+ (bei.docstatus < 2)
+ & (bom.name == bom_no)
+ & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
+ )
+ .groupby(bei.item_code, bei.stock_uom)
+ ).run(as_dict=True)
+
+ for d in data:
if not d.conversion_factor and d.purchase_uom:
d.conversion_factor = get_uom_conversion_factor(d.item_code, d.purchase_uom)
item_details.setdefault(d.get("item_code"), d)
@@ -836,33 +946,47 @@ def get_subitems(
parent_qty,
planned_qty=1,
):
- items = frappe.db.sql(
- """
- SELECT
- bom_item.item_code, default_material_request_type, item.item_name,
- ifnull(%(parent_qty)s * sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * %(planned_qty)s, 0) as qty,
- item.is_sub_contracted_item as is_sub_contracted, bom_item.source_warehouse,
- item.default_bom as default_bom, bom_item.description as description,
- bom_item.stock_uom as stock_uom, item.min_order_qty as min_order_qty, item.safety_stock as safety_stock,
- item_default.default_warehouse, item.purchase_uom, item_uom.conversion_factor
- FROM
- `tabBOM Item` bom_item
- JOIN `tabBOM` bom ON bom.name = bom_item.parent
- JOIN `tabItem` item ON bom_item.item_code = item.name
- LEFT JOIN `tabItem Default` item_default
- ON item.name = item_default.parent and item_default.company = %(company)s
- LEFT JOIN `tabUOM Conversion Detail` item_uom
- ON item.name = item_uom.parent and item_uom.uom = item.purchase_uom
- where
- bom.name = %(bom)s
- and bom_item.docstatus < 2
- and item.is_stock_item in (1, {0})
- group by bom_item.item_code""".format(
- 0 if include_non_stock_items else 1
- ),
- {"bom": bom_no, "parent_qty": parent_qty, "planned_qty": planned_qty, "company": company},
- as_dict=1,
- )
+ bom_item = frappe.qb.DocType("BOM Item")
+ bom = frappe.qb.DocType("BOM")
+ item = frappe.qb.DocType("Item")
+ item_default = frappe.qb.DocType("Item Default")
+ item_uom = frappe.qb.DocType("UOM Conversion Detail")
+
+ items = (
+ frappe.qb.from_(bom_item)
+ .join(bom)
+ .on(bom.name == bom_item.parent)
+ .join(item)
+ .on(bom_item.item_code == item.name)
+ .left_join(item_default)
+ .on((item.name == item_default.parent) & (item_default.company == company))
+ .left_join(item_uom)
+ .on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
+ .select(
+ bom_item.item_code,
+ item.default_material_request_type,
+ item.item_name,
+ IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_(
+ "qty"
+ ),
+ item.is_sub_contracted_item.as_("is_sub_contracted"),
+ bom_item.source_warehouse,
+ item.default_bom.as_("default_bom"),
+ bom_item.description.as_("description"),
+ bom_item.stock_uom.as_("stock_uom"),
+ item.min_order_qty.as_("min_order_qty"),
+ item.safety_stock.as_("safety_stock"),
+ item_default.default_warehouse,
+ item.purchase_uom,
+ item_uom.conversion_factor,
+ )
+ .where(
+ (bom.name == bom_no)
+ & (bom_item.docstatus < 2)
+ & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
+ )
+ .groupby(bom_item.item_code)
+ ).run(as_dict=True)
for d in items:
if not data.get("include_exploded_items") or not d.default_bom:
@@ -925,11 +1049,25 @@ def get_material_request_items(
if include_safety_stock:
required_qty += flt(row["safety_stock"])
+ item_details = frappe.get_cached_value(
+ "Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1
+ )
+
+ conversion_factor = 1.0
+ if (
+ row.get("default_material_request_type") == "Purchase"
+ and item_details.purchase_uom
+ and item_details.purchase_uom != item_details.stock_uom
+ ):
+ conversion_factor = (
+ get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
+ )
+
if required_qty > 0:
return {
"item_code": row.item_code,
"item_name": row.item_name,
- "quantity": required_qty,
+ "quantity": required_qty / conversion_factor,
"required_bom_qty": total_qty,
"stock_uom": row.get("stock_uom"),
"warehouse": warehouse
@@ -950,48 +1088,69 @@ def get_material_request_items(
def get_sales_orders(self):
- so_filter = item_filter = ""
- bom_item = "bom.item = so_item.item_code"
+ bom = frappe.qb.DocType("BOM")
+ pi = frappe.qb.DocType("Packed Item")
+ so = frappe.qb.DocType("Sales Order")
+ so_item = frappe.qb.DocType("Sales Order Item")
+
+ open_so_subquery1 = frappe.qb.from_(bom).select(bom.name).where(bom.is_active == 1)
+
+ open_so_subquery2 = (
+ frappe.qb.from_(pi)
+ .select(pi.name)
+ .where(
+ (pi.parent == so.name)
+ & (pi.parent_item == so_item.item_code)
+ & (
+ ExistsCriterion(
+ frappe.qb.from_(bom).select(bom.name).where((bom.item == pi.item_code) & (bom.is_active == 1))
+ )
+ )
+ )
+ )
+
+ open_so_query = (
+ frappe.qb.from_(so)
+ .from_(so_item)
+ .select(so.name, so.transaction_date, so.customer, so.base_grand_total)
+ .distinct()
+ .where(
+ (so_item.parent == so.name)
+ & (so.docstatus == 1)
+ & (so.status.notin(["Stopped", "Closed"]))
+ & (so.company == self.company)
+ & (so_item.qty > so_item.work_order_qty)
+ )
+ )
date_field_mapper = {
- "from_date": (">=", "so.transaction_date"),
- "to_date": ("<=", "so.transaction_date"),
- "from_delivery_date": (">=", "so_item.delivery_date"),
- "to_delivery_date": ("<=", "so_item.delivery_date"),
+ "from_date": self.from_date >= so.transaction_date,
+ "to_date": self.to_date <= so.transaction_date,
+ "from_delivery_date": self.from_delivery_date >= so_item.delivery_date,
+ "to_delivery_date": self.to_delivery_date <= so_item.delivery_date,
}
for field, value in date_field_mapper.items():
if self.get(field):
- so_filter += f" and {value[1]} {value[0]} %({field})s"
+ open_so_query = open_so_query.where(value)
- for field in ["customer", "project", "sales_order_status"]:
+ for field in ("customer", "project", "sales_order_status"):
if self.get(field):
so_field = "status" if field == "sales_order_status" else field
- so_filter += f" and so.{so_field} = %({field})s"
+ open_so_query = open_so_query.where(so[so_field] == self.get(field))
if self.item_code and frappe.db.exists("Item", self.item_code):
- bom_item = self.get_bom_item() or bom_item
- item_filter += " and so_item.item_code = %(item_code)s"
+ open_so_query = open_so_query.where(so_item.item_code == self.item_code)
+ open_so_subquery1 = open_so_subquery1.where(
+ self.get_bom_item_condition() or bom.item == so_item.item_code
+ )
- open_so = frappe.db.sql(
- f"""
- select distinct so.name, so.transaction_date, so.customer, so.base_grand_total
- from `tabSales Order` so, `tabSales Order Item` so_item
- where so_item.parent = so.name
- and so.docstatus = 1 and so.status not in ('Stopped', 'Closed')
- and so.company = %(company)s
- and so_item.qty > so_item.work_order_qty {so_filter} {item_filter}
- and (exists (select name from `tabBOM` bom where {bom_item}
- and bom.is_active = 1)
- or exists (select name from `tabPacked Item` pi
- where pi.parent = so.name and pi.parent_item = so_item.item_code
- and exists (select name from `tabBOM` bom where bom.item=pi.item_code
- and bom.is_active = 1)))
- """,
- self.as_dict(),
- as_dict=1,
+ open_so_query = open_so_query.where(
+ (ExistsCriterion(open_so_subquery1) | ExistsCriterion(open_so_subquery2))
)
+ open_so = open_so_query.run(as_dict=True)
+
return open_so
@@ -1000,37 +1159,35 @@ def get_bin_details(row, company, for_warehouse=None, all_warehouse=False):
if isinstance(row, str):
row = frappe._dict(json.loads(row))
- company = frappe.db.escape(company)
- conditions, warehouse = "", ""
+ bin = frappe.qb.DocType("Bin")
+ wh = frappe.qb.DocType("Warehouse")
- conditions = " and warehouse in (select name from `tabWarehouse` where company = {0})".format(
- company
- )
+ subquery = frappe.qb.from_(wh).select(wh.name).where(wh.company == company)
+
+ warehouse = ""
if not all_warehouse:
warehouse = for_warehouse or row.get("source_warehouse") or row.get("default_warehouse")
if warehouse:
lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"])
- conditions = """ and warehouse in (select name from `tabWarehouse`
- where lft >= {0} and rgt <= {1} and name=`tabBin`.warehouse and company = {2})
- """.format(
- lft, rgt, company
- )
+ subquery = subquery.where((wh.lft >= lft) & (wh.rgt <= rgt) & (wh.name == bin.warehouse))
- return frappe.db.sql(
- """ select ifnull(sum(projected_qty),0) as projected_qty,
- ifnull(sum(actual_qty),0) as actual_qty, ifnull(sum(ordered_qty),0) as ordered_qty,
- ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse,
- ifnull(sum(planned_qty),0) as planned_qty
- from `tabBin` where item_code = %(item_code)s {conditions}
- group by item_code, warehouse
- """.format(
- conditions=conditions
- ),
- {"item_code": row["item_code"]},
- as_dict=1,
+ query = (
+ frappe.qb.from_(bin)
+ .select(
+ bin.warehouse,
+ IfNull(Sum(bin.projected_qty), 0).as_("projected_qty"),
+ IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
+ IfNull(Sum(bin.ordered_qty), 0).as_("ordered_qty"),
+ IfNull(Sum(bin.reserved_qty_for_production), 0).as_("reserved_qty_for_production"),
+ IfNull(Sum(bin.planned_qty), 0).as_("planned_qty"),
+ )
+ .where((bin.item_code == row["item_code"]) & (bin.warehouse.isin(subquery)))
+ .groupby(bin.item_code, bin.warehouse)
)
+ return query.run(as_dict=True)
+
@frappe.whitelist()
def get_so_details(sales_order):
@@ -1073,6 +1230,21 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
doc["mr_items"] = []
po_items = doc.get("po_items") if doc.get("po_items") else doc.get("items")
+
+ if doc.get("sub_assembly_items"):
+ for sa_row in doc.sub_assembly_items:
+ sa_row = frappe._dict(sa_row)
+ if sa_row.type_of_manufacturing == "Material Request":
+ po_items.append(
+ frappe._dict(
+ {
+ "item_code": sa_row.production_item,
+ "required_qty": sa_row.qty,
+ "include_exploded_items": 0,
+ }
+ )
+ )
+
# Check for empty table or empty rows
if not po_items or not [row.get("item_code") for row in po_items if row.get("item_code")]:
frappe.throw(
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 040e791e00..2bf14c24cf 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -11,8 +11,10 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_warehouse_list,
)
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
+from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo
+from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
-from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
@@ -280,6 +282,31 @@ class TestProductionPlan(FrappeTestCase):
pln.reload()
pln.cancel()
+ def test_production_plan_subassembly_default_supplier(self):
+ from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+
+ bom_tree_1 = {"Test Laptop": {"Test Motherboard": {"Test Motherboard Wires": {}}}}
+ bom = create_nested_bom(bom_tree_1, prefix="")
+
+ item_doc = frappe.get_doc("Item", "Test Motherboard")
+ company = "_Test Company"
+
+ item_doc.is_sub_contracted_item = 1
+ for row in item_doc.item_defaults:
+ if row.company == company and not row.default_supplier:
+ row.default_supplier = "_Test Supplier"
+
+ if not item_doc.item_defaults:
+ item_doc.append("item_defaults", {"company": company, "default_supplier": "_Test Supplier"})
+
+ item_doc.save()
+
+ plan = create_production_plan(item_code="Test Laptop", use_multi_level_bom=1, do_not_submit=True)
+ plan.get_sub_assembly_items()
+ plan.set_default_supplier_for_subcontracting_order()
+
+ self.assertEqual(plan.sub_assembly_items[0].supplier, "_Test Supplier")
+
def test_production_plan_combine_subassembly(self):
"""
Test combining Sub assembly items belonging to the same BOM in Prod Plan.
@@ -583,19 +610,22 @@ class TestProductionPlan(FrappeTestCase):
Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel)
"""
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
- from erpnext.manufacturing.doctype.work_order.work_order import (
- make_stock_entry as make_se_from_wo,
+
+ make_stock_entry(item_code="_Test Item", target="Work In Progress - _TC", qty=2, basic_rate=100)
+ make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="Work In Progress - _TC", qty=4, basic_rate=100
)
- make_stock_entry(
- item_code="Raw Material Item 1", target="Work In Progress - _TC", qty=2, basic_rate=100
- )
- make_stock_entry(
- item_code="Raw Material Item 2", target="Work In Progress - _TC", qty=2, basic_rate=100
- )
+ item = "_Test FG Item"
- item = "Test Production Item 1"
- so = make_sales_order(item_code=item, qty=1)
+ make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=1)
+
+ so = make_sales_order(item_code=item, qty=2)
+
+ dn = make_delivery_note(so.name)
+ dn.items[0].qty = 1
+ dn.save()
+ dn.submit()
pln = create_production_plan(
company=so.company, get_items_from="Sales Order", sales_order=so, skip_getting_mr_items=True
@@ -629,9 +659,6 @@ class TestProductionPlan(FrappeTestCase):
def test_production_plan_pending_qty_independent_items(self):
"Test Prod Plan impact if items are added independently (no from SO or MR)."
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
- from erpnext.manufacturing.doctype.work_order.work_order import (
- make_stock_entry as make_se_from_wo,
- )
make_stock_entry(
item_code="Raw Material Item 1", target="Work In Progress - _TC", qty=2, basic_rate=100
@@ -728,6 +755,119 @@ class TestProductionPlan(FrappeTestCase):
for po_item, subassy_item in zip(pp.po_items, pp.sub_assembly_items):
self.assertEqual(po_item.name, subassy_item.production_plan_item)
+ def test_produced_qty_for_multi_level_bom_item(self):
+ # Create Items and BOMs
+ rm_item = make_item(properties={"is_stock_item": 1}).name
+ sub_assembly_item = make_item(properties={"is_stock_item": 1}).name
+ fg_item = make_item(properties={"is_stock_item": 1}).name
+
+ make_stock_entry(
+ item_code=rm_item,
+ qty=60,
+ to_warehouse="Work In Progress - _TC",
+ rate=99,
+ purpose="Material Receipt",
+ )
+
+ make_bom(item=sub_assembly_item, raw_materials=[rm_item], rm_qty=3)
+ make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4)
+
+ # Step - 1: Create Production Plan
+ pln = create_production_plan(item_code=fg_item, planned_qty=5, skip_getting_mr_items=1)
+ pln.get_sub_assembly_items()
+
+ # Step - 2: Create Work Orders
+ pln.make_work_order()
+ work_orders = frappe.get_all("Work Order", filters={"production_plan": pln.name}, pluck="name")
+ sa_wo = fg_wo = None
+ for work_order in work_orders:
+ wo_doc = frappe.get_doc("Work Order", work_order)
+ if wo_doc.production_plan_item:
+ wo_doc.update(
+ {"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Finished Goods - _TC"}
+ )
+ fg_wo = wo_doc.name
+ else:
+ wo_doc.update(
+ {"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Work In Progress - _TC"}
+ )
+ sa_wo = wo_doc.name
+ wo_doc.submit()
+
+ # Step - 3: Complete Work Orders
+ se = frappe.get_doc(make_se_from_wo(sa_wo, "Manufacture"))
+ se.submit()
+
+ se = frappe.get_doc(make_se_from_wo(fg_wo, "Manufacture"))
+ se.submit()
+
+ # Step - 4: Check Production Plan Item Produced Qty
+ pln.load_from_db()
+ self.assertEqual(pln.status, "Completed")
+ self.assertEqual(pln.po_items[0].produced_qty, 5)
+
+ def test_material_request_item_for_purchase_uom(self):
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
+ bom_item = make_item(
+ properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
+ ).name
+
+ if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
+ doc = frappe.get_doc("Item", bom_item)
+ doc.append("uoms", {"uom": "Nos", "conversion_factor": 10})
+ doc.save()
+
+ make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
+
+ pln = create_production_plan(
+ item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1"
+ )
+
+ pln.make_material_request()
+
+ for row in pln.mr_items:
+ self.assertEqual(row.uom, "Nos")
+ self.assertEqual(row.quantity, 1)
+
+ for row in frappe.get_all(
+ "Material Request Item",
+ filters={"production_plan": pln.name},
+ fields=["item_code", "uom", "qty"],
+ ):
+ self.assertEqual(row.item_code, bom_item)
+ self.assertEqual(row.uom, "Nos")
+ self.assertEqual(row.qty, 1)
+
+ def test_material_request_for_sub_assembly_items(self):
+ from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+
+ bom_tree = {
+ "Fininshed Goods1 For MR": {
+ "SubAssembly1 For MR": {"SubAssembly1-1 For MR": {"ChildPart1 For MR": {}}}
+ }
+ }
+
+ parent_bom = create_nested_bom(bom_tree, prefix="")
+ plan = create_production_plan(
+ item_code=parent_bom.item, planned_qty=10, ignore_existing_ordered_qty=1, do_not_submit=1
+ )
+
+ plan.get_sub_assembly_items()
+
+ mr_items = []
+ for row in plan.sub_assembly_items:
+ mr_items.append(row.production_item)
+ row.type_of_manufacturing = "Material Request"
+
+ plan.save()
+ items = get_items_for_material_requests(plan.as_dict())
+
+ validate_mr_items = [d.get("item_code") for d in items]
+ for item_code in mr_items:
+ self.assertTrue(item_code in validate_mr_items)
+
def create_production_plan(**args):
"""
diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
index df5862fcac..0688278e09 100644
--- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
+++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json
@@ -83,7 +83,7 @@
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
- "label": "For Warehouse",
+ "label": "FG Warehouse",
"options": "Warehouse"
},
{
@@ -216,7 +216,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-24 04:54:09.940224",
+ "modified": "2022-11-25 14:15:40.061514",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Item",
diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
index 45ea26c3a8..4eb6bf6ecf 100644
--- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
+++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
@@ -169,7 +169,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Manufacturing Type",
- "options": "In House\nSubcontract"
+ "options": "In House\nSubcontract\nMaterial Request"
},
{
"fieldname": "supplier",
@@ -188,7 +188,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-01-30 21:31:10.527559",
+ "modified": "2022-11-28 13:50:15.116082",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index b556d9974a..729ed42f51 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -5,7 +5,7 @@ import copy
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
-from frappe.utils import add_days, add_months, cint, flt, now, today
+from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
@@ -17,6 +17,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
close_work_order,
make_job_card,
make_stock_entry,
+ make_stock_return_entry,
stop_unstop,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -26,6 +27,8 @@ from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
+test_dependencies = ["BOM"]
+
class TestWorkOrder(FrappeTestCase):
def setUp(self):
@@ -632,6 +635,10 @@ class TestWorkOrder(FrappeTestCase):
bom.submit()
bom_name = bom.name
+ ste1 = test_stock_entry.make_stock_entry(
+ item_code=rm1, target="_Test Warehouse - _TC", qty=32, basic_rate=5000.0
+ )
+
work_order = make_wo_order_test_record(
item=fg_item, skip_transfer=True, planned_start_date=now(), qty=1
)
@@ -656,11 +663,29 @@ class TestWorkOrder(FrappeTestCase):
work_order.insert()
work_order.submit()
self.assertEqual(work_order.has_batch_no, 1)
- ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 30))
+ batches = frappe.get_all("Batch", filters={"reference_name": work_order.name})
+ self.assertEqual(len(batches), 3)
+ batches = [batch.name for batch in batches]
+
+ ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 10))
for row in ste1.get("items"):
if row.is_finished_item:
self.assertEqual(row.item_code, fg_item)
self.assertEqual(row.qty, 10)
+ self.assertTrue(row.batch_no in batches)
+ batches.remove(row.batch_no)
+
+ ste1.submit()
+
+ remaining_batches = []
+ ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 20))
+ for row in ste1.get("items"):
+ if row.is_finished_item:
+ self.assertEqual(row.item_code, fg_item)
+ self.assertEqual(row.qty, 10)
+ remaining_batches.append(row.batch_no)
+
+ self.assertEqual(sorted(remaining_batches), sorted(batches))
frappe.db.set_value("Manufacturing Settings", None, "make_serial_no_batch_from_work_order", 0)
@@ -821,20 +846,20 @@ class TestWorkOrder(FrappeTestCase):
create_process_loss_bom_items,
)
- qty = 4
+ qty = 10
scrap_qty = 0.25 # bom item qty = 1, consider as 25% of FG
source_warehouse = "Stores - _TC"
wip_warehouse = "_Test Warehouse - _TC"
fg_item_non_whole, _, bom_item = create_process_loss_bom_items()
test_stock_entry.make_stock_entry(
- item_code=bom_item.item_code, target=source_warehouse, qty=4, basic_rate=100
+ item_code=bom_item.item_code, target=source_warehouse, qty=qty, basic_rate=100
)
bom_no = f"BOM-{fg_item_non_whole.item_code}-001"
if not frappe.db.exists("BOM", bom_no):
bom_doc = create_bom_with_process_loss_item(
- fg_item_non_whole, bom_item, scrap_qty=scrap_qty, scrap_rate=0, fg_qty=1, is_process_loss=1
+ fg_item_non_whole, bom_item, fg_qty=1, process_loss_percentage=10
)
bom_doc.submit()
@@ -858,19 +883,15 @@ class TestWorkOrder(FrappeTestCase):
# Testing stock entry values
items = se.get("items")
- self.assertEqual(len(items), 3, "There should be 3 items including process loss.")
+ self.assertEqual(len(items), 2, "There should be 3 items including process loss.")
+ fg_item = items[1]
- source_item, fg_item, pl_item = items
+ self.assertEqual(fg_item.qty, qty - 1)
+ self.assertEqual(se.process_loss_percentage, 10)
+ self.assertEqual(se.process_loss_qty, 1)
- total_pl_qty = qty * scrap_qty
- actual_fg_qty = qty - total_pl_qty
-
- self.assertEqual(pl_item.qty, total_pl_qty)
- self.assertEqual(fg_item.qty, actual_fg_qty)
-
- # Testing Work Order values
- self.assertEqual(frappe.db.get_value("Work Order", wo.name, "produced_qty"), qty)
- self.assertEqual(frappe.db.get_value("Work Order", wo.name, "process_loss_qty"), total_pl_qty)
+ wo.load_from_db()
+ self.assertEqual(wo.status, "In Process")
@timeout(seconds=60)
def test_job_card_scrap_item(self):
@@ -1129,6 +1150,36 @@ class TestWorkOrder(FrappeTestCase):
except frappe.MandatoryError:
self.fail("Batch generation causing failing in Work Order")
+ @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1})
+ def test_auto_serial_no_creation(self):
+ from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+
+ fg_item = frappe.generate_hash(length=20)
+ child_item = frappe.generate_hash(length=20)
+
+ bom_tree = {fg_item: {child_item: {}}}
+
+ create_nested_bom(bom_tree, prefix="")
+
+ item = frappe.get_doc("Item", fg_item)
+ item.has_serial_no = 1
+ item.serial_no_series = f"{item.name}.#####"
+ item.save()
+
+ try:
+ wo_order = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True)
+ serial_nos = wo_order.serial_no
+ stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
+ stock_entry.set_work_order_details()
+ stock_entry.set_serial_no_batch_for_finished_good()
+ for row in stock_entry.items:
+ if row.item_code == fg_item:
+ self.assertTrue(row.serial_no)
+ self.assertEqual(sorted(get_serial_nos(row.serial_no)), sorted(get_serial_nos(serial_nos)))
+
+ except frappe.MandatoryError:
+ self.fail("Batch generation causing failing in Work Order")
+
@change_settings(
"Manufacturing Settings",
{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
@@ -1406,6 +1457,237 @@ class TestWorkOrder(FrappeTestCase):
)
self.assertEqual(manufacture_ste_doc2.items[1].qty, 1)
+ def test_non_consumed_material_return_against_work_order(self):
+ frappe.db.set_value(
+ "Manufacturing Settings",
+ None,
+ "backflush_raw_materials_based_on",
+ "Material Transferred for Manufacture",
+ )
+
+ item = make_item(
+ "Test FG Item To Test Return Case",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ item_code = item.name
+ bom_doc = make_bom(
+ item=item_code,
+ source_warehouse="Stores - _TC",
+ raw_materials=["Test Batch MCC Keyboard", "Test Serial No BTT Headphone"],
+ )
+
+ # Create a work order
+ wo_doc = make_wo_order_test_record(production_item=item_code, qty=5)
+ wo_doc.save()
+
+ self.assertEqual(wo_doc.bom_no, bom_doc.name)
+
+ # Transfer material for manufacture
+ ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 5))
+ for row in ste_doc.items:
+ row.qty += 2
+ row.transfer_qty += 2
+ nste_doc = test_stock_entry.make_stock_entry(
+ item_code=row.item_code, target="Stores - _TC", qty=row.qty, basic_rate=100
+ )
+
+ row.batch_no = nste_doc.items[0].batch_no
+ row.serial_no = nste_doc.items[0].serial_no
+
+ ste_doc.save()
+ ste_doc.submit()
+ ste_doc.load_from_db()
+
+ # Create a stock entry to manufacture the item
+ ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 5))
+ for row in ste_doc.items:
+ if row.s_warehouse and not row.t_warehouse:
+ row.qty -= 2
+ row.transfer_qty -= 2
+
+ if row.serial_no:
+ serial_nos = get_serial_nos(row.serial_no)
+ row.serial_no = "\n".join(serial_nos[0:5])
+
+ ste_doc.save()
+ ste_doc.submit()
+
+ wo_doc.load_from_db()
+ for row in wo_doc.required_items:
+ self.assertEqual(row.transferred_qty, 7)
+ self.assertEqual(row.consumed_qty, 5)
+
+ self.assertEqual(wo_doc.status, "Completed")
+ return_ste_doc = make_stock_return_entry(wo_doc.name)
+ return_ste_doc.save()
+
+ self.assertTrue(return_ste_doc.is_return)
+ for row in return_ste_doc.items:
+ self.assertEqual(row.qty, 2)
+
+ def test_workstation_type_for_work_order(self):
+ prepare_data_for_workstation_type_check()
+
+ workstation_types = ["Workstation Type 1", "Workstation Type 2", "Workstation Type 3"]
+ planned_start_date = "2022-11-14 10:00:00"
+
+ wo_order = make_wo_order_test_record(
+ item="Test FG Item For Workstation Type", planned_start_date=planned_start_date, qty=2
+ )
+
+ job_cards = frappe.get_all(
+ "Job Card",
+ fields=[
+ "`tabJob Card`.`name`",
+ "`tabJob Card`.`workstation_type`",
+ "`tabJob Card`.`workstation`",
+ "`tabJob Card Time Log`.`from_time`",
+ "`tabJob Card Time Log`.`to_time`",
+ "`tabJob Card Time Log`.`time_in_mins`",
+ ],
+ filters=[
+ ["Job Card", "work_order", "=", wo_order.name],
+ ["Job Card Time Log", "docstatus", "=", 1],
+ ],
+ order_by="`tabJob Card`.`creation` desc",
+ )
+
+ workstations_to_check = ["Workstation 1", "Workstation 3", "Workstation 5"]
+ for index, row in enumerate(job_cards):
+ if index != 0:
+ planned_start_date = add_to_date(planned_start_date, minutes=40)
+
+ self.assertEqual(row.workstation_type, workstation_types[index])
+ self.assertEqual(row.from_time, planned_start_date)
+ self.assertEqual(row.to_time, add_to_date(planned_start_date, minutes=30))
+ self.assertEqual(row.workstation, workstations_to_check[index])
+
+ planned_start_date = "2022-11-14 10:00:00"
+
+ wo_order = make_wo_order_test_record(
+ item="Test FG Item For Workstation Type", planned_start_date=planned_start_date, qty=2
+ )
+
+ job_cards = frappe.get_all(
+ "Job Card",
+ fields=[
+ "`tabJob Card`.`name`",
+ "`tabJob Card`.`workstation_type`",
+ "`tabJob Card`.`workstation`",
+ "`tabJob Card Time Log`.`from_time`",
+ "`tabJob Card Time Log`.`to_time`",
+ "`tabJob Card Time Log`.`time_in_mins`",
+ ],
+ filters=[
+ ["Job Card", "work_order", "=", wo_order.name],
+ ["Job Card Time Log", "docstatus", "=", 1],
+ ],
+ order_by="`tabJob Card`.`creation` desc",
+ )
+
+ workstations_to_check = ["Workstation 2", "Workstation 4", "Workstation 6"]
+ for index, row in enumerate(job_cards):
+ if index != 0:
+ planned_start_date = add_to_date(planned_start_date, minutes=40)
+
+ self.assertEqual(row.workstation_type, workstation_types[index])
+ self.assertEqual(row.from_time, planned_start_date)
+ self.assertEqual(row.to_time, add_to_date(planned_start_date, minutes=30))
+ self.assertEqual(row.workstation, workstations_to_check[index])
+
+
+def prepare_data_for_workstation_type_check():
+ from erpnext.manufacturing.doctype.operation.test_operation import make_operation
+ from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
+ from erpnext.manufacturing.doctype.workstation_type.test_workstation_type import (
+ create_workstation_type,
+ )
+
+ workstation_types = ["Workstation Type 1", "Workstation Type 2", "Workstation Type 3"]
+ for workstation_type in workstation_types:
+ create_workstation_type(workstation_type=workstation_type)
+
+ operations = ["Cutting", "Sewing", "Packing"]
+ for operation in operations:
+ make_operation(
+ {
+ "operation": operation,
+ }
+ )
+
+ workstations = [
+ {
+ "workstation": "Workstation 1",
+ "workstation_type": "Workstation Type 1",
+ },
+ {
+ "workstation": "Workstation 2",
+ "workstation_type": "Workstation Type 1",
+ },
+ {
+ "workstation": "Workstation 3",
+ "workstation_type": "Workstation Type 2",
+ },
+ {
+ "workstation": "Workstation 4",
+ "workstation_type": "Workstation Type 2",
+ },
+ {
+ "workstation": "Workstation 5",
+ "workstation_type": "Workstation Type 3",
+ },
+ {
+ "workstation": "Workstation 6",
+ "workstation_type": "Workstation Type 3",
+ },
+ ]
+
+ for row in workstations:
+ make_workstation(row)
+
+ fg_item = make_item(
+ "Test FG Item For Workstation Type",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ rm_item = make_item(
+ "Test RM Item For Workstation Type",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ if not frappe.db.exists("BOM", {"item": fg_item.name}):
+ bom_doc = make_bom(
+ item=fg_item.name,
+ source_warehouse="Stores - _TC",
+ raw_materials=[rm_item.name],
+ do_not_submit=True,
+ )
+
+ submit_bom = False
+ for index, operation in enumerate(operations):
+ if not frappe.db.exists("BOM Operation", {"parent": bom_doc.name, "operation": operation}):
+ bom_doc.append(
+ "operations",
+ {
+ "operation": operation,
+ "time_in_mins": 30,
+ "hour_rate": 100,
+ "workstation_type": workstation_types[index],
+ },
+ )
+
+ submit_bom = True
+
+ if submit_bom:
+ bom_doc.submit()
+
def prepare_data_for_backflush_based_on_materials_transferred():
batch_item_doc = make_item(
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index 20f15039ef..4aff42cb73 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -180,6 +180,37 @@ frappe.ui.form.on("Work Order", {
frm.trigger("make_bom");
});
}
+
+ frm.trigger("add_custom_button_to_return_components");
+ },
+
+ add_custom_button_to_return_components: function(frm) {
+ if (frm.doc.docstatus === 1 && in_list(["Closed", "Completed"], frm.doc.status)) {
+ let non_consumed_items = frm.doc.required_items.filter(d =>{
+ return flt(d.consumed_qty) < flt(d.transferred_qty - d.returned_qty)
+ });
+
+ if (non_consumed_items && non_consumed_items.length) {
+ frm.add_custom_button(__("Return Components"), function() {
+ frm.trigger("create_stock_return_entry");
+ }).addClass("btn-primary");
+ }
+ }
+ },
+
+ create_stock_return_entry: function(frm) {
+ frappe.call({
+ method: "erpnext.manufacturing.doctype.work_order.work_order.make_stock_return_entry",
+ args: {
+ "work_order": frm.doc.name,
+ },
+ callback: function(r) {
+ if(!r.exc) {
+ let doc = frappe.model.sync(r.message);
+ frappe.set_route("Form", doc[0].doctype, doc[0].name);
+ }
+ }
+ });
},
make_job_card: function(frm) {
@@ -415,7 +446,6 @@ frappe.ui.form.on("Work Order", {
frm.fields_dict.required_items.grid.toggle_reqd("source_warehouse", true);
frm.toggle_reqd("transfer_material_against",
frm.doc.operations && frm.doc.operations.length > 0);
- frm.fields_dict.operations.grid.toggle_reqd("workstation", frm.doc.operations);
},
set_sales_order: function(frm) {
@@ -517,7 +547,8 @@ frappe.ui.form.on("Work Order Operation", {
erpnext.work_order = {
set_custom_buttons: function(frm) {
var doc = frm.doc;
- if (doc.docstatus === 1 && doc.status != "Closed") {
+
+ if (doc.status !== "Closed") {
frm.add_custom_button(__('Close'), function() {
frappe.confirm(__("Once the Work Order is Closed. It can't be resumed."),
() => {
@@ -525,7 +556,9 @@ erpnext.work_order = {
}
);
}, __("Status"));
+ }
+ if (doc.docstatus === 1 && !in_list(["Closed", "Completed"], doc.status)) {
if (doc.status != 'Stopped' && doc.status != 'Completed') {
frm.add_custom_button(__('Stop'), function() {
erpnext.work_order.change_work_order_status(frm, "Stopped");
@@ -555,13 +588,10 @@ erpnext.work_order = {
}
}
- if(!frm.doc.skip_transfer){
+ if (frm.doc.status != 'Stopped') {
// If "Material Consumption is check in Manufacturing Settings, allow Material Consumption
- if ((flt(doc.produced_qty) < flt(doc.material_transferred_for_manufacturing))
- && frm.doc.status != 'Stopped') {
- frm.has_finish_btn = true;
-
- if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
+ if (frm.doc.__onload && frm.doc.__onload.material_consumption == 1) {
+ if (flt(doc.material_transferred_for_manufacturing) > 0 || frm.doc.skip_transfer) {
// Only show "Material Consumption" when required_qty > consumed_qty
var counter = 0;
var tbl = frm.doc.required_items || [];
@@ -580,26 +610,47 @@ erpnext.work_order = {
consumption_btn.addClass('btn-primary');
}
}
+ }
- var finish_btn = frm.add_custom_button(__('Finish'), function() {
- erpnext.work_order.make_se(frm, 'Manufacture');
- });
+ if(!frm.doc.skip_transfer){
+ if (flt(doc.material_transferred_for_manufacturing) > 0) {
+ if ((flt(doc.produced_qty) < flt(doc.material_transferred_for_manufacturing))) {
+ frm.has_finish_btn = true;
- if(doc.material_transferred_for_manufacturing>=doc.qty) {
- // all materials transferred for manufacturing, make this primary
+ var finish_btn = frm.add_custom_button(__('Finish'), function() {
+ erpnext.work_order.make_se(frm, 'Manufacture');
+ });
+
+ if(doc.material_transferred_for_manufacturing>=doc.qty) {
+ // all materials transferred for manufacturing, make this primary
+ finish_btn.addClass('btn-primary');
+ }
+ } else {
+ frappe.db.get_doc("Manufacturing Settings").then((doc) => {
+ let allowance_percentage = doc.overproduction_percentage_for_work_order;
+
+ if (allowance_percentage > 0) {
+ let allowed_qty = frm.doc.qty + ((allowance_percentage / 100) * frm.doc.qty);
+
+ if ((flt(doc.produced_qty) < allowed_qty)) {
+ frm.add_custom_button(__('Finish'), function() {
+ erpnext.work_order.make_se(frm, 'Manufacture');
+ });
+ }
+ }
+ });
+ }
+ }
+ } else {
+ if ((flt(doc.produced_qty) < flt(doc.qty))) {
+ var finish_btn = frm.add_custom_button(__('Finish'), function() {
+ erpnext.work_order.make_se(frm, 'Manufacture');
+ });
finish_btn.addClass('btn-primary');
}
}
- } else {
- if ((flt(doc.produced_qty) < flt(doc.qty)) && frm.doc.status != 'Stopped') {
- var finish_btn = frm.add_custom_button(__('Finish'), function() {
- erpnext.work_order.make_se(frm, 'Manufacture');
- });
- finish_btn.addClass('btn-primary');
- }
}
}
-
},
calculate_cost: function(doc) {
if (doc.operations){
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json
index 9452a63d70..25e16d6337 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.json
+++ b/erpnext/manufacturing/doctype/work_order/work_order.json
@@ -14,13 +14,13 @@
"item_name",
"image",
"bom_no",
+ "sales_order",
"column_break1",
"company",
"qty",
"material_transferred_for_manufacturing",
"produced_qty",
"process_loss_qty",
- "sales_order",
"project",
"serial_no_and_batch_for_finished_good_section",
"has_serial_no",
@@ -28,6 +28,7 @@
"column_break_17",
"serial_no",
"batch_size",
+ "work_order_configuration",
"settings_section",
"allow_alternative_item",
"use_multi_level_bom",
@@ -42,7 +43,11 @@
"fg_warehouse",
"scrap_warehouse",
"required_items_section",
+ "materials_and_operations_tab",
"required_items",
+ "operations_section",
+ "operations",
+ "transfer_material_against",
"time",
"planned_start_date",
"planned_end_date",
@@ -51,9 +56,6 @@
"actual_start_date",
"actual_end_date",
"lead_time",
- "operations_section",
- "transfer_material_against",
- "operations",
"section_break_22",
"planned_operating_cost",
"actual_operating_cost",
@@ -72,12 +74,14 @@
"production_plan_item",
"production_plan_sub_assembly_item",
"product_bundle_item",
- "amended_from"
+ "amended_from",
+ "connections_tab"
],
"fields": [
{
"fieldname": "item",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
+ "label": "Production Item",
"options": "fa fa-gift"
},
{
@@ -236,7 +240,7 @@
{
"fieldname": "warehouses",
"fieldtype": "Section Break",
- "label": "Warehouses",
+ "label": "Warehouse",
"options": "fa fa-building"
},
{
@@ -390,8 +394,8 @@
{
"collapsible": 1,
"fieldname": "more_info",
- "fieldtype": "Section Break",
- "label": "More Information",
+ "fieldtype": "Tab Break",
+ "label": "More Info",
"options": "fa fa-file-text"
},
{
@@ -474,8 +478,7 @@
},
{
"fieldname": "settings_section",
- "fieldtype": "Section Break",
- "label": "Settings"
+ "fieldtype": "Section Break"
},
{
"fieldname": "column_break_18",
@@ -568,6 +571,22 @@
"no_copy": 1,
"non_negative": 1,
"read_only": 1
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections",
+ "show_dashboard": 1
+ },
+ {
+ "fieldname": "work_order_configuration",
+ "fieldtype": "Tab Break",
+ "label": "Configuration"
+ },
+ {
+ "fieldname": "materials_and_operations_tab",
+ "fieldtype": "Tab Break",
+ "label": "Materials & Operations"
}
],
"icon": "fa fa-cogs",
@@ -575,7 +594,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
- "modified": "2022-01-24 21:18:12.160114",
+ "modified": "2023-01-03 14:16:35.427731",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 7b8625372a..ae9e9c6962 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -20,6 +20,7 @@ from frappe.utils import (
nowdate,
time_diff_in_hours,
)
+from pypika import functions as fn
from erpnext.manufacturing.doctype.bom.bom import (
get_bom_item_rate,
@@ -86,11 +87,18 @@ class WorkOrder(Document):
self.validate_transfer_against()
self.validate_operation_time()
self.status = self.get_status()
+ self.validate_workstation_type()
validate_uom_is_integer(self, "stock_uom", ["qty", "produced_qty"])
self.set_required_items(reset_only_qty=len(self.get("required_items")))
+ def validate_workstation_type(self):
+ for row in self.operations:
+ if not row.workstation and not row.workstation_type:
+ msg = f"Row {row.idx}: Workstation or Workstation Type is mandatory for an operation {row.operation}"
+ frappe.throw(_(msg))
+
def validate_sales_order(self):
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -145,7 +153,7 @@ class WorkOrder(Document):
frappe.throw(_("Sales Order {0} is {1}").format(self.sales_order, status))
def set_default_warehouse(self):
- if not self.wip_warehouse:
+ if not self.wip_warehouse and not self.skip_transfer:
self.wip_warehouse = frappe.db.get_single_value(
"Manufacturing Settings", "default_wip_warehouse"
)
@@ -238,21 +246,11 @@ class WorkOrder(Document):
status = "Draft"
elif self.docstatus == 1:
if status != "Stopped":
- stock_entries = frappe._dict(
- frappe.db.sql(
- """select purpose, sum(fg_completed_qty)
- from `tabStock Entry` where work_order=%s and docstatus=1
- group by purpose""",
- self.name,
- )
- )
-
status = "Not Started"
- if stock_entries:
+ if flt(self.material_transferred_for_manufacturing) > 0:
status = "In Process"
- produced_qty = stock_entries.get("Manufacture")
- if flt(produced_qty) >= flt(self.qty):
- status = "Completed"
+ if flt(self.produced_qty) >= flt(self.qty):
+ status = "Completed"
else:
status = "Cancelled"
@@ -277,14 +275,7 @@ class WorkOrder(Document):
):
continue
- qty = flt(
- frappe.db.sql(
- """select sum(fg_completed_qty)
- from `tabStock Entry` where work_order=%s and docstatus=1
- and purpose=%s""",
- (self.name, purpose),
- )[0][0]
- )
+ qty = self.get_transferred_or_manufactured_qty(purpose)
completed_qty = self.qty + (allowance_percentage / 100 * self.qty)
if qty > completed_qty:
@@ -306,26 +297,30 @@ class WorkOrder(Document):
if self.production_plan:
self.update_production_plan_status()
- def set_process_loss_qty(self):
- process_loss_qty = flt(
- frappe.db.sql(
- """
- SELECT sum(qty) FROM `tabStock Entry Detail`
- WHERE
- is_process_loss=1
- AND parent IN (
- SELECT name FROM `tabStock Entry`
- WHERE
- work_order=%s
- AND purpose='Manufacture'
- AND docstatus=1
- )
- """,
- (self.name,),
- )[0][0]
+ def get_transferred_or_manufactured_qty(self, purpose):
+ table = frappe.qb.DocType("Stock Entry")
+ query = frappe.qb.from_(table).where(
+ (table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose)
)
- if process_loss_qty is not None:
- self.db_set("process_loss_qty", process_loss_qty)
+
+ if purpose == "Manufacture":
+ query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty))
+ else:
+ query = query.select(Sum(table.fg_completed_qty))
+
+ return flt(query.run()[0][0])
+
+ def set_process_loss_qty(self):
+ table = frappe.qb.DocType("Stock Entry")
+ process_loss_qty = (
+ frappe.qb.from_(table)
+ .select(Sum(table.process_loss_qty))
+ .where(
+ (table.work_order == self.name) & (table.purpose == "Manufacture") & (table.docstatus == 1)
+ )
+ ).run()[0][0]
+
+ self.db_set("process_loss_qty", flt(process_loss_qty))
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.production_plan)
@@ -344,6 +339,7 @@ class WorkOrder(Document):
produced_qty = total_qty[0][0] if total_qty else 0
+ self.update_status()
production_plan.run_method(
"update_produced_pending_qty", produced_qty, self.production_plan_item
)
@@ -372,7 +368,7 @@ class WorkOrder(Document):
def on_cancel(self):
self.validate_cancel()
- frappe.db.set(self, "status", "Cancelled")
+ self.db_set("status", "Cancelled")
if self.production_plan and frappe.db.exists(
"Production Plan Item Reference", {"parent": self.production_plan}
@@ -490,11 +486,6 @@ class WorkOrder(Document):
def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(index, row)
- if not row.workstation:
- frappe.throw(
- _("Row {0}: select the workstation against the operation {1}").format(row.idx, row.operation)
- )
-
original_start_time = row.planned_start_time
job_card_doc = create_job_card(
self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
@@ -661,6 +652,7 @@ class WorkOrder(Document):
"description",
"workstation",
"idx",
+ "workstation_type",
"base_hour_rate as hour_rate",
"time_in_mins",
"parent as bom",
@@ -859,6 +851,7 @@ class WorkOrder(Document):
if self.docstatus == 1:
# calculate transferred qty based on submitted stock entries
self.update_transferred_qty_for_required_items()
+ self.update_returned_qty()
# update in bin
self.update_reserved_qty_for_production()
@@ -930,23 +923,62 @@ class WorkOrder(Document):
self.set_available_qty()
def update_transferred_qty_for_required_items(self):
- """update transferred qty from submitted stock entries for that item against
- the work order"""
+ ste = frappe.qb.DocType("Stock Entry")
+ ste_child = frappe.qb.DocType("Stock Entry Detail")
- for d in self.required_items:
- transferred_qty = frappe.db.sql(
- """select sum(qty)
- from `tabStock Entry` entry, `tabStock Entry Detail` detail
- where
- entry.work_order = %(name)s
- and entry.purpose = 'Material Transfer for Manufacture'
- and entry.docstatus = 1
- and detail.parent = entry.name
- and (detail.item_code = %(item)s or detail.original_item = %(item)s)""",
- {"name": self.name, "item": d.item_code},
- )[0][0]
+ query = (
+ frappe.qb.from_(ste)
+ .inner_join(ste_child)
+ .on((ste_child.parent == ste.name))
+ .select(
+ ste_child.item_code,
+ ste_child.original_item,
+ fn.Sum(ste_child.qty).as_("qty"),
+ )
+ .where(
+ (ste.docstatus == 1)
+ & (ste.work_order == self.name)
+ & (ste.purpose == "Material Transfer for Manufacture")
+ & (ste.is_return == 0)
+ )
+ .groupby(ste_child.item_code)
+ )
- d.db_set("transferred_qty", flt(transferred_qty), update_modified=False)
+ data = query.run(as_dict=1) or []
+ transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
+
+ for row in self.required_items:
+ row.db_set(
+ "transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False
+ )
+
+ def update_returned_qty(self):
+ ste = frappe.qb.DocType("Stock Entry")
+ ste_child = frappe.qb.DocType("Stock Entry Detail")
+
+ query = (
+ frappe.qb.from_(ste)
+ .inner_join(ste_child)
+ .on((ste_child.parent == ste.name))
+ .select(
+ ste_child.item_code,
+ ste_child.original_item,
+ fn.Sum(ste_child.qty).as_("qty"),
+ )
+ .where(
+ (ste.docstatus == 1)
+ & (ste.work_order == self.name)
+ & (ste.purpose == "Material Transfer for Manufacture")
+ & (ste.is_return == 1)
+ )
+ .groupby(ste_child.item_code)
+ )
+
+ data = query.run(as_dict=1) or []
+ returned_dict = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
+
+ for row in self.required_items:
+ row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
def update_consumed_qty_for_required_items(self):
"""
@@ -1357,6 +1389,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
doc.update(
{
"work_order": work_order.name,
+ "workstation_type": row.get("workstation_type"),
"operation": row.get("operation"),
"workstation": row.get("workstation"),
"posting_date": nowdate(),
@@ -1470,3 +1503,25 @@ def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
)
)
).run()[0][0] or 0.0
+
+
+@frappe.whitelist()
+def make_stock_return_entry(work_order):
+ from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials
+
+ non_consumed_items = get_available_materials(work_order)
+ if not non_consumed_items:
+ return
+
+ wo_doc = frappe.get_cached_doc("Work Order", work_order)
+
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.from_bom = 1
+ stock_entry.is_return = 1
+ stock_entry.work_order = work_order
+ stock_entry.purpose = "Material Transfer for Manufacture"
+ stock_entry.bom_no = wo_doc.bom_no
+ stock_entry.add_transfered_raw_materials_in_items()
+ stock_entry.set_stock_entry_type()
+
+ return stock_entry
diff --git a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py
index 465460f95d..d0dcc55932 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py
@@ -7,6 +7,6 @@ def get_data():
"non_standard_fieldnames": {"Batch": "reference_name"},
"transactions": [
{"label": _("Transactions"), "items": ["Stock Entry", "Job Card", "Pick List"]},
- {"label": _("Reference"), "items": ["Serial No", "Batch"]},
+ {"label": _("Reference"), "items": ["Serial No", "Batch", "Material Request"]},
],
}
diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
index 3acf5727d1..f354d45381 100644
--- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
+++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
@@ -20,6 +20,7 @@
"column_break_11",
"transferred_qty",
"consumed_qty",
+ "returned_qty",
"available_qty_at_source_warehouse",
"available_qty_at_wip_warehouse"
],
@@ -97,6 +98,7 @@
"fieldtype": "Column Break"
},
{
+ "columns": 1,
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "consumed_qty",
"fieldtype": "Float",
@@ -127,11 +129,19 @@
"fieldtype": "Currency",
"label": "Amount",
"read_only": 1
+ },
+ {
+ "columns": 1,
+ "fieldname": "returned_qty",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Returned Qty ",
+ "read_only": 1
}
],
"istable": 1,
"links": [],
- "modified": "2020-04-13 18:46:32.966416",
+ "modified": "2022-09-28 10:50:43.512562",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",
@@ -140,5 +150,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
index 4e1a464cb0..31b920145e 100644
--- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
+++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
@@ -10,6 +10,7 @@
"completed_qty",
"column_break_4",
"bom",
+ "workstation_type",
"workstation",
"sequence_id",
"section_break_10",
@@ -196,12 +197,18 @@
{
"fieldname": "section_break_10",
"fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "workstation_type",
+ "fieldtype": "Link",
+ "label": "Workstation Type",
+ "options": "Workstation Type"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-11-29 16:37:18.824489",
+ "modified": "2022-11-09 01:37:56.563068",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",
@@ -209,5 +216,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py
index 6db985c8c2..1eb47ae577 100644
--- a/erpnext/manufacturing/doctype/workstation/test_workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py
@@ -107,6 +107,7 @@ def make_workstation(*args, **kwargs):
doc = frappe.get_doc({"doctype": "Workstation", "workstation_name": workstation_name})
doc.hour_rate_rent = args.get("hour_rate_rent")
doc.hour_rate_labour = args.get("hour_rate_labour")
+ doc.workstation_type = args.get("workstation_type")
doc.insert()
return doc
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js
index 5b9cedb6f9..f830b170ed 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.js
+++ b/erpnext/manufacturing/doctype/workstation/workstation.js
@@ -2,7 +2,7 @@
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Workstation", {
- onload: function(frm) {
+ onload(frm) {
if(frm.is_new())
{
frappe.call({
@@ -15,6 +15,18 @@ frappe.ui.form.on("Workstation", {
}
})
}
+ },
+
+ workstation_type(frm) {
+ if (frm.doc.workstation_type) {
+ frm.call({
+ method: "set_data_based_on_workstation_type",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.refresh_fields();
+ }
+ })
+ }
}
});
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.json b/erpnext/manufacturing/doctype/workstation/workstation.json
index d130391cec..881cba0cce 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.json
+++ b/erpnext/manufacturing/doctype/workstation/workstation.json
@@ -1,26 +1,30 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:workstation_name",
"creation": "2013-01-10 16:34:17",
"doctype": "DocType",
"document_type": "Setup",
+ "engine": "InnoDB",
"field_order": [
"workstation_name",
"production_capacity",
"column_break_3",
+ "workstation_type",
"over_heads",
"hour_rate_electricity",
"hour_rate_consumable",
"column_break_11",
"hour_rate_rent",
"hour_rate_labour",
+ "section_break_11",
"hour_rate",
+ "workstaion_description",
+ "description",
"working_hours_section",
"holiday_list",
- "working_hours",
- "workstaion_description",
- "description"
+ "working_hours"
],
"fields": [
{
@@ -44,7 +48,7 @@
},
{
"fieldname": "over_heads",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Operating Costs",
"oldfieldtype": "Section Break"
},
@@ -99,7 +103,7 @@
},
{
"fieldname": "working_hours_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Working Hours"
},
{
@@ -128,16 +132,29 @@
{
"collapsible": 1,
"fieldname": "workstaion_description",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Description"
+ },
+ {
+ "bold": 1,
+ "fieldname": "workstation_type",
+ "fieldtype": "Link",
+ "label": "Workstation Type",
+ "options": "Workstation Type"
+ },
+ {
+ "fieldname": "section_break_11",
+ "fieldtype": "Section Break"
}
],
"icon": "icon-wrench",
"idx": 1,
- "modified": "2019-11-26 12:39:19.742052",
+ "links": [],
+ "modified": "2022-11-04 17:39:01.549346",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Workstation",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -154,6 +171,8 @@
],
"quick_entry": 1,
"show_name_in_global_search": 1,
+ "sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py
index 59e5318ab8..d5b6d37d67 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/workstation.py
@@ -32,7 +32,11 @@ class OverlapError(frappe.ValidationError):
class Workstation(Document):
- def validate(self):
+ def before_save(self):
+ self.set_data_based_on_workstation_type()
+ self.set_hour_rate()
+
+ def set_hour_rate(self):
self.hour_rate = (
flt(self.hour_rate_labour)
+ flt(self.hour_rate_electricity)
@@ -40,6 +44,30 @@ class Workstation(Document):
+ flt(self.hour_rate_rent)
)
+ @frappe.whitelist()
+ def set_data_based_on_workstation_type(self):
+ if self.workstation_type:
+ fields = [
+ "hour_rate_labour",
+ "hour_rate_electricity",
+ "hour_rate_consumable",
+ "hour_rate_rent",
+ "hour_rate",
+ "description",
+ ]
+
+ data = frappe.get_cached_value("Workstation Type", self.workstation_type, fields, as_dict=True)
+
+ if not data:
+ return
+
+ for field in fields:
+ if self.get(field):
+ continue
+
+ if value := data.get(field):
+ self.set(field, value)
+
def on_update(self):
self.validate_overlap_for_operation_timings()
self.update_bom_operation()
@@ -100,9 +128,7 @@ def get_default_holiday_list():
def check_if_within_operating_hours(workstation, operation, from_datetime, to_datetime):
if from_datetime and to_datetime:
- if not cint(
- frappe.db.get_value("Manufacturing Settings", "None", "allow_production_on_holidays")
- ):
+ if not frappe.db.get_single_value("Manufacturing Settings", "allow_production_on_holidays"):
check_workstation_for_holiday(workstation, from_datetime, to_datetime)
if not cint(frappe.db.get_value("Manufacturing Settings", None, "allow_overtime")):
diff --git a/erpnext/regional/saudi_arabia/__init__.py b/erpnext/manufacturing/doctype/workstation_type/__init__.py
similarity index 100%
rename from erpnext/regional/saudi_arabia/__init__.py
rename to erpnext/manufacturing/doctype/workstation_type/__init__.py
diff --git a/erpnext/manufacturing/doctype/workstation_type/test_workstation_type.py b/erpnext/manufacturing/doctype/workstation_type/test_workstation_type.py
new file mode 100644
index 0000000000..aa7a3ee92f
--- /dev/null
+++ b/erpnext/manufacturing/doctype/workstation_type/test_workstation_type.py
@@ -0,0 +1,21 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestWorkstationType(FrappeTestCase):
+ pass
+
+
+def create_workstation_type(**args):
+ args = frappe._dict(args)
+
+ if workstation_type := frappe.db.exists("Workstation Type", args.workstation_type):
+ return frappe.get_doc("Workstation Type", workstation_type)
+ else:
+ doc = frappe.new_doc("Workstation Type")
+ doc.update(args)
+ doc.insert()
+ return doc
diff --git a/erpnext/manufacturing/doctype/workstation_type/workstation_type.js b/erpnext/manufacturing/doctype/workstation_type/workstation_type.js
new file mode 100644
index 0000000000..419fa6c10a
--- /dev/null
+++ b/erpnext/manufacturing/doctype/workstation_type/workstation_type.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Workstation Type', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/manufacturing/doctype/workstation_type/workstation_type.json b/erpnext/manufacturing/doctype/workstation_type/workstation_type.json
new file mode 100644
index 0000000000..7d9e36abb4
--- /dev/null
+++ b/erpnext/manufacturing/doctype/workstation_type/workstation_type.json
@@ -0,0 +1,133 @@
+{
+ "actions": [],
+ "allow_import": 1,
+ "allow_rename": 1,
+ "autoname": "field:workstation_type",
+ "creation": "2022-11-04 17:03:23.334818",
+ "default_view": "List",
+ "doctype": "DocType",
+ "document_type": "Setup",
+ "engine": "InnoDB",
+ "field_order": [
+ "workstation_type",
+ "over_heads",
+ "hour_rate_electricity",
+ "hour_rate_consumable",
+ "column_break_5",
+ "hour_rate_rent",
+ "hour_rate_labour",
+ "section_break_8",
+ "hour_rate",
+ "description_tab",
+ "description"
+ ],
+ "fields": [
+ {
+ "fieldname": "workstation_type",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Workstation Type",
+ "oldfieldname": "workstation_name",
+ "oldfieldtype": "Data",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "over_heads",
+ "fieldtype": "Section Break",
+ "label": "Operating Costs",
+ "oldfieldtype": "Section Break"
+ },
+ {
+ "description": "per hour",
+ "fieldname": "hour_rate_electricity",
+ "fieldtype": "Currency",
+ "label": "Electricity Cost",
+ "oldfieldname": "hour_rate_electricity",
+ "oldfieldtype": "Currency"
+ },
+ {
+ "description": "per hour",
+ "fieldname": "hour_rate_consumable",
+ "fieldtype": "Currency",
+ "label": "Consumable Cost",
+ "oldfieldname": "hour_rate_consumable",
+ "oldfieldtype": "Currency"
+ },
+ {
+ "description": "per hour",
+ "fieldname": "hour_rate_rent",
+ "fieldtype": "Currency",
+ "label": "Rent Cost",
+ "oldfieldname": "hour_rate_rent",
+ "oldfieldtype": "Currency"
+ },
+ {
+ "description": "Wages per hour",
+ "fieldname": "hour_rate_labour",
+ "fieldtype": "Currency",
+ "label": "Wages",
+ "oldfieldname": "hour_rate_labour",
+ "oldfieldtype": "Currency"
+ },
+ {
+ "description": "per hour",
+ "fieldname": "hour_rate",
+ "fieldtype": "Currency",
+ "label": "Net Hour Rate",
+ "oldfieldname": "hour_rate",
+ "oldfieldtype": "Currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Description",
+ "oldfieldname": "description",
+ "oldfieldtype": "Text",
+ "width": "300px"
+ },
+ {
+ "fieldname": "column_break_5",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "description_tab",
+ "fieldtype": "Tab Break",
+ "label": "Description"
+ },
+ {
+ "fieldname": "section_break_8",
+ "fieldtype": "Section Break"
+ }
+ ],
+ "icon": "icon-wrench",
+ "links": [],
+ "modified": "2022-11-16 23:11:36.224249",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "Workstation Type",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Manufacturing User",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "show_name_in_global_search": 1,
+ "sort_field": "modified",
+ "sort_order": "ASC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/workstation_type/workstation_type.py b/erpnext/manufacturing/doctype/workstation_type/workstation_type.py
new file mode 100644
index 0000000000..348f4f8a16
--- /dev/null
+++ b/erpnext/manufacturing/doctype/workstation_type/workstation_type.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+from frappe.utils import flt
+
+
+class WorkstationType(Document):
+ def before_save(self):
+ self.set_hour_rate()
+
+ def set_hour_rate(self):
+ self.hour_rate = (
+ flt(self.hour_rate_labour)
+ + flt(self.hour_rate_electricity)
+ + flt(self.hour_rate_consumable)
+ + flt(self.hour_rate_rent)
+ )
+
+
+def get_workstations(workstation_type):
+ workstations = frappe.get_all("Workstation", filters={"workstation_type": workstation_type})
+
+ return [workstation.name for workstation in workstations]
diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js
index 0d5bfcbaf4..a0fd91e866 100644
--- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js
+++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js
@@ -11,17 +11,24 @@ frappe.query_reports["BOM Stock Calculated"] = {
"options": "BOM",
"reqd": 1
},
- {
- "fieldname": "qty_to_make",
- "label": __("Quantity to Make"),
- "fieldtype": "Int",
- "default": "1"
- },
-
- {
+ {
+ "fieldname": "warehouse",
+ "label": __("Warehouse"),
+ "fieldtype": "Link",
+ "options": "Warehouse",
+ },
+ {
+ "fieldname": "qty_to_make",
+ "label": __("Quantity to Make"),
+ "fieldtype": "Float",
+ "default": "1.0",
+ "reqd": 1
+ },
+ {
"fieldname": "show_exploded_view",
"label": __("Show exploded view"),
- "fieldtype": "Check"
+ "fieldtype": "Check",
+ "default": false,
}
]
}
diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
index 933be3e014..550445c1f7 100644
--- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
+++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
@@ -4,29 +4,31 @@
import frappe
from frappe import _
+from frappe.query_builder.functions import IfNull, Sum
from frappe.utils.data import comma_and
+from pypika.terms import ExistsCriterion
def execute(filters=None):
- # if not filters: filters = {}
columns = get_columns()
- summ_data = []
+ data = []
- data = get_bom_stock(filters)
+ bom_data = get_bom_data(filters)
qty_to_make = filters.get("qty_to_make")
-
manufacture_details = get_manufacturer_records()
- for row in data:
- reqd_qty = qty_to_make * row.actual_qty
- last_pur_price = frappe.db.get_value("Item", row.item_code, "last_purchase_rate")
- summ_data.append(get_report_data(last_pur_price, reqd_qty, row, manufacture_details))
- return columns, summ_data
+ for row in bom_data:
+ required_qty = qty_to_make * row.qty_per_unit
+ last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate")
+
+ data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details))
+
+ return columns, data
-def get_report_data(last_pur_price, reqd_qty, row, manufacture_details):
- to_build = row.to_build if row.to_build > 0 else 0
- diff_qty = to_build - reqd_qty
+def get_report_data(last_purchase_rate, required_qty, row, manufacture_details):
+ qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0
+ difference_qty = row.actual_qty - required_qty
return [
row.item_code,
row.description,
@@ -34,85 +36,126 @@ def get_report_data(last_pur_price, reqd_qty, row, manufacture_details):
comma_and(
manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False
),
+ qty_per_unit,
row.actual_qty,
- str(to_build),
- reqd_qty,
- diff_qty,
- last_pur_price,
+ required_qty,
+ difference_qty,
+ last_purchase_rate,
]
def get_columns():
- """return columns"""
- columns = [
- _("Item") + ":Link/Item:100",
- _("Description") + "::150",
- _("Manufacturer") + "::250",
- _("Manufacturer Part Number") + "::250",
- _("Qty") + ":Float:50",
- _("Stock Qty") + ":Float:100",
- _("Reqd Qty") + ":Float:100",
- _("Diff Qty") + ":Float:100",
- _("Last Purchase Price") + ":Float:100",
+ return [
+ {
+ "fieldname": "item",
+ "label": _("Item"),
+ "fieldtype": "Link",
+ "options": "Item",
+ "width": 120,
+ },
+ {
+ "fieldname": "description",
+ "label": _("Description"),
+ "fieldtype": "Data",
+ "width": 150,
+ },
+ {
+ "fieldname": "manufacturer",
+ "label": _("Manufacturer"),
+ "fieldtype": "Data",
+ "width": 120,
+ },
+ {
+ "fieldname": "manufacturer_part_number",
+ "label": _("Manufacturer Part Number"),
+ "fieldtype": "Data",
+ "width": 150,
+ },
+ {
+ "fieldname": "qty_per_unit",
+ "label": _("Qty Per Unit"),
+ "fieldtype": "Float",
+ "width": 110,
+ },
+ {
+ "fieldname": "available_qty",
+ "label": _("Available Qty"),
+ "fieldtype": "Float",
+ "width": 120,
+ },
+ {
+ "fieldname": "required_qty",
+ "label": _("Required Qty"),
+ "fieldtype": "Float",
+ "width": 120,
+ },
+ {
+ "fieldname": "difference_qty",
+ "label": _("Difference Qty"),
+ "fieldtype": "Float",
+ "width": 130,
+ },
+ {
+ "fieldname": "last_purchase_rate",
+ "label": _("Last Purchase Rate"),
+ "fieldtype": "Float",
+ "width": 160,
+ },
]
- return columns
-def get_bom_stock(filters):
- conditions = ""
- bom = filters.get("bom")
-
- table = "`tabBOM Item`"
- qty_field = "qty"
-
+def get_bom_data(filters):
if filters.get("show_exploded_view"):
- table = "`tabBOM Explosion Item`"
- qty_field = "stock_qty"
+ bom_item_table = "BOM Explosion Item"
+ else:
+ bom_item_table = "BOM Item"
+
+ bom_item = frappe.qb.DocType(bom_item_table)
+ bin = frappe.qb.DocType("Bin")
+
+ query = (
+ frappe.qb.from_(bom_item)
+ .left_join(bin)
+ .on(bom_item.item_code == bin.item_code)
+ .select(
+ bom_item.item_code,
+ bom_item.description,
+ bom_item.qty_consumed_per_unit.as_("qty_per_unit"),
+ IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
+ )
+ .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
+ .groupby(bom_item.item_code)
+ )
if filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
)
+
if warehouse_details:
- conditions += (
- " and exists (select name from `tabWarehouse` wh \
- where wh.lft >= %s and wh.rgt <= %s and ledger.warehouse = wh.name)"
- % (warehouse_details.lft, warehouse_details.rgt)
+ wh = frappe.qb.DocType("Warehouse")
+ query = query.where(
+ ExistsCriterion(
+ frappe.qb.from_(wh)
+ .select(wh.name)
+ .where(
+ (wh.lft >= warehouse_details.lft)
+ & (wh.rgt <= warehouse_details.rgt)
+ & (bin.warehouse == wh.name)
+ )
+ )
)
else:
- conditions += " and ledger.warehouse = %s" % frappe.db.escape(filters.get("warehouse"))
+ query = query.where(bin.warehouse == filters.get("warehouse"))
- else:
- conditions += ""
-
- return frappe.db.sql(
- """
- SELECT
- bom_item.item_code,
- bom_item.description,
- bom_item.{qty_field},
- ifnull(sum(ledger.actual_qty), 0) as actual_qty,
- ifnull(sum(FLOOR(ledger.actual_qty / bom_item.{qty_field})), 0) as to_build
- FROM
- {table} AS bom_item
- LEFT JOIN `tabBin` AS ledger
- ON bom_item.item_code = ledger.item_code
- {conditions}
-
- WHERE
- bom_item.parent = '{bom}' and bom_item.parenttype='BOM'
-
- GROUP BY bom_item.item_code""".format(
- qty_field=qty_field, table=table, conditions=conditions, bom=bom
- ),
- as_dict=1,
- )
+ return query.run(as_dict=True)
def get_manufacturer_records():
details = frappe.get_all(
"Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"]
)
+
manufacture_details = frappe._dict()
for detail in details:
dic = manufacture_details.setdefault(detail.get("item_code"), {})
diff --git a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py
new file mode 100644
index 0000000000..8ad980fa19
--- /dev/null
+++ b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py
@@ -0,0 +1,115 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+from erpnext.manufacturing.report.bom_stock_calculated.bom_stock_calculated import (
+ execute as bom_stock_calculated_report,
+)
+from erpnext.stock.doctype.item.test_item import make_item
+
+
+class TestBOMStockCalculated(FrappeTestCase):
+ def setUp(self):
+ self.fg_item, self.rm_items = create_items()
+ self.boms = create_boms(self.fg_item, self.rm_items)
+
+ def test_bom_stock_calculated(self):
+ qty_to_make = 10
+
+ # Case 1: When Item(s) Qty and Stock Qty are equal.
+ data = bom_stock_calculated_report(
+ filters={
+ "qty_to_make": qty_to_make,
+ "bom": self.boms[0].name,
+ }
+ )[1]
+ expected_data = get_expected_data(self.boms[0], qty_to_make)
+ self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
+
+ # Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1.
+ data = bom_stock_calculated_report(
+ filters={
+ "qty_to_make": qty_to_make,
+ "bom": self.boms[1].name,
+ }
+ )[1]
+ expected_data = get_expected_data(self.boms[1], qty_to_make)
+ self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
+
+ # Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1.
+ data = bom_stock_calculated_report(
+ filters={
+ "qty_to_make": qty_to_make,
+ "bom": self.boms[2].name,
+ }
+ )[1]
+ expected_data = get_expected_data(self.boms[2], qty_to_make)
+ self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
+
+
+def create_items():
+ fg_item = make_item(properties={"is_stock_item": 1}).name
+ rm_item1 = make_item(
+ properties={
+ "is_stock_item": 1,
+ "standard_rate": 100,
+ "opening_stock": 100,
+ "last_purchase_rate": 100,
+ }
+ ).name
+ rm_item2 = make_item(
+ properties={
+ "is_stock_item": 1,
+ "standard_rate": 200,
+ "opening_stock": 200,
+ "last_purchase_rate": 200,
+ }
+ ).name
+
+ return fg_item, [rm_item1, rm_item2]
+
+
+def create_boms(fg_item, rm_items):
+ def update_bom_items(bom, uom, conversion_factor):
+ for item in bom.items:
+ item.uom = uom
+ item.conversion_factor = conversion_factor
+
+ return bom
+
+ bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10)
+
+ bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
+ bom2 = update_bom_items(bom2, "Box", 10)
+ bom2.save()
+ bom2.submit()
+
+ bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
+ bom3 = update_bom_items(bom3, "Box", 10)
+ bom3.save()
+ bom3.submit()
+
+ return [bom1, bom2, bom3]
+
+
+def get_expected_data(bom, qty_to_make):
+ expected_data = []
+
+ for idx in range(len(bom.items)):
+ expected_data.append(
+ [
+ bom.items[idx].item_code,
+ bom.items[idx].item_code,
+ "",
+ "",
+ float(bom.items[idx].stock_qty / bom.quantity),
+ float(100 * (idx + 1)),
+ float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)),
+ float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))),
+ float(100 * (idx + 1)),
+ ]
+ )
+
+ return expected_data
diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js
index 7beecaceed..e7f67caf24 100644
--- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js
+++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js
@@ -25,8 +25,9 @@ frappe.query_reports["BOM Stock Report"] = {
],
"formatter": function(value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
+
if (column.id == "item") {
- if (data["enough_parts_to_build"] > 0) {
+ if (data["in_stock_qty"] >= data["required_qty"]) {
value = `${data['item']}`;
} else {
value = `${data['item']}`;
diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py
index 34e9826305..cdf1541f88 100644
--- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py
+++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py
@@ -4,6 +4,8 @@
import frappe
from frappe import _
+from frappe.query_builder.functions import Sum
+from pypika.terms import ExistsCriterion
def execute(filters=None):
@@ -11,7 +13,6 @@ def execute(filters=None):
filters = {}
columns = get_columns()
-
data = get_bom_stock(filters)
return columns, data
@@ -33,59 +34,57 @@ def get_columns():
def get_bom_stock(filters):
- conditions = ""
- bom = filters.get("bom")
-
- table = "`tabBOM Item`"
- qty_field = "stock_qty"
-
- qty_to_produce = filters.get("qty_to_produce", 1)
- if int(qty_to_produce) <= 0:
+ qty_to_produce = filters.get("qty_to_produce") or 1
+ if int(qty_to_produce) < 0:
frappe.throw(_("Quantity to Produce can not be less than Zero"))
if filters.get("show_exploded_view"):
- table = "`tabBOM Explosion Item`"
+ bom_item_table = "BOM Explosion Item"
+ else:
+ bom_item_table = "BOM Item"
+
+ bin = frappe.qb.DocType("Bin")
+ bom = frappe.qb.DocType("BOM")
+ bom_item = frappe.qb.DocType(bom_item_table)
+
+ query = (
+ frappe.qb.from_(bom)
+ .inner_join(bom_item)
+ .on(bom.name == bom_item.parent)
+ .left_join(bin)
+ .on(bom_item.item_code == bin.item_code)
+ .select(
+ bom_item.item_code,
+ bom_item.description,
+ bom_item.stock_qty,
+ bom_item.stock_uom,
+ (bom_item.stock_qty / bom.quantity) * qty_to_produce,
+ Sum(bin.actual_qty),
+ Sum(bin.actual_qty) / (bom_item.stock_qty / bom.quantity),
+ )
+ .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
+ .groupby(bom_item.item_code)
+ )
if filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
)
+
if warehouse_details:
- conditions += (
- " and exists (select name from `tabWarehouse` wh \
- where wh.lft >= %s and wh.rgt <= %s and ledger.warehouse = wh.name)"
- % (warehouse_details.lft, warehouse_details.rgt)
+ wh = frappe.qb.DocType("Warehouse")
+ query = query.where(
+ ExistsCriterion(
+ frappe.qb.from_(wh)
+ .select(wh.name)
+ .where(
+ (wh.lft >= warehouse_details.lft)
+ & (wh.rgt <= warehouse_details.rgt)
+ & (bin.warehouse == wh.name)
+ )
+ )
)
else:
- conditions += " and ledger.warehouse = %s" % frappe.db.escape(filters.get("warehouse"))
+ query = query.where(bin.warehouse == filters.get("warehouse"))
- else:
- conditions += ""
-
- return frappe.db.sql(
- """
- SELECT
- bom_item.item_code,
- bom_item.description ,
- bom_item.{qty_field},
- bom_item.stock_uom,
- bom_item.{qty_field} * {qty_to_produce} / bom.quantity,
- sum(ledger.actual_qty) as actual_qty,
- sum(FLOOR(ledger.actual_qty / (bom_item.{qty_field} * {qty_to_produce} / bom.quantity)))
- FROM
- `tabBOM` AS bom INNER JOIN {table} AS bom_item
- ON bom.name = bom_item.parent
- LEFT JOIN `tabBin` AS ledger
- ON bom_item.item_code = ledger.item_code
- {conditions}
- WHERE
- bom_item.parent = {bom} and bom_item.parenttype='BOM'
-
- GROUP BY bom_item.item_code""".format(
- qty_field=qty_field,
- table=table,
- conditions=conditions,
- bom=frappe.db.escape(bom),
- qty_to_produce=qty_to_produce or 1,
- )
- )
+ return query.run()
diff --git a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
index da283435b9..70a1850fd0 100644
--- a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
+++ b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
@@ -64,22 +64,21 @@ def get_columns(filters):
def get_data(filters):
- cond = "1=1"
+ wo = frappe.qb.DocType("Work Order")
+ query = (
+ frappe.qb.from_(wo)
+ .select(wo.name.as_("work_order"), wo.qty, wo.produced_qty, wo.production_item, wo.bom_no)
+ .where((wo.produced_qty > wo.qty) & (wo.docstatus == 1))
+ )
if filters.get("bom_no") and not filters.get("work_order"):
- cond += " and bom_no = '%s'" % filters.get("bom_no")
+ query = query.where(wo.bom_no == filters.get("bom_no"))
if filters.get("work_order"):
- cond += " and name = '%s'" % filters.get("work_order")
+ query = query.where(wo.name == filters.get("work_order"))
results = []
- for d in frappe.db.sql(
- """ select name as work_order, qty, produced_qty, production_item, bom_no
- from `tabWork Order` where produced_qty > qty and docstatus = 1 and {0}""".format(
- cond
- ),
- as_dict=1,
- ):
+ for d in query.run(as_dict=True):
results.append(d)
for data in frappe.get_all(
@@ -95,16 +94,17 @@ def get_data(filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_work_orders(doctype, txt, searchfield, start, page_len, filters):
- cond = "1=1"
- if filters.get("bom_no"):
- cond += " and bom_no = '%s'" % filters.get("bom_no")
-
- return frappe.db.sql(
- """select name from `tabWork Order`
- where name like %(name)s and {0} and produced_qty > qty and docstatus = 1
- order by name limit {2} offset {1}""".format(
- cond, start, page_len
- ),
- {"name": "%%%s%%" % txt},
- as_list=1,
+ wo = frappe.qb.DocType("Work Order")
+ query = (
+ frappe.qb.from_(wo)
+ .select(wo.name)
+ .where((wo.name.like(f"{txt}%")) & (wo.produced_qty > wo.qty) & (wo.docstatus == 1))
+ .orderby(wo.name)
+ .limit(page_len)
+ .offset(start)
)
+
+ if filters.get("bom_no"):
+ query = query.where(wo.bom_no == filters.get("bom_no"))
+
+ return query.run(as_list=True)
diff --git a/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py b/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py
index 7500744c22..d3bce83155 100644
--- a/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py
+++ b/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py
@@ -96,38 +96,39 @@ class ForecastingReport(ExponentialSmoothingForecast):
value["avg"] = flt(sum(list_of_period_value)) / flt(sum(total_qty))
def get_data_for_forecast(self):
- cond = ""
- if self.filters.item_code:
- cond = " AND soi.item_code = %s" % (frappe.db.escape(self.filters.item_code))
-
- warehouses = []
- if self.filters.warehouse:
- warehouses = get_child_warehouses(self.filters.warehouse)
- cond += " AND soi.warehouse in ({})".format(",".join(["%s"] * len(warehouses)))
-
- input_data = [self.filters.from_date, self.filters.company]
- if warehouses:
- input_data.extend(warehouses)
+ parent = frappe.qb.DocType(self.doctype)
+ child = frappe.qb.DocType(self.child_doctype)
date_field = "posting_date" if self.doctype == "Delivery Note" else "transaction_date"
- return frappe.db.sql(
- """
- SELECT
- so.{date_field} as posting_date, soi.item_code, soi.warehouse,
- soi.item_name, soi.stock_qty as qty, soi.base_amount as amount
- FROM
- `tab{doc}` so, `tab{child_doc}` soi
- WHERE
- so.docstatus = 1 AND so.name = soi.parent AND
- so.{date_field} < %s AND so.company = %s {cond}
- """.format(
- doc=self.doctype, child_doc=self.child_doctype, date_field=date_field, cond=cond
- ),
- tuple(input_data),
- as_dict=1,
+ query = (
+ frappe.qb.from_(parent)
+ .from_(child)
+ .select(
+ parent[date_field].as_("posting_date"),
+ child.item_code,
+ child.warehouse,
+ child.item_name,
+ child.stock_qty.as_("qty"),
+ child.base_amount.as_("amount"),
+ )
+ .where(
+ (parent.docstatus == 1)
+ & (parent.name == child.parent)
+ & (parent[date_field] < self.filters.from_date)
+ & (parent.company == self.filters.company)
+ )
)
+ if self.filters.item_code:
+ query = query.where(child.item_code == self.filters.item_code)
+
+ if self.filters.warehouse:
+ warehouses = get_child_warehouses(self.filters.warehouse) or []
+ query = query.where(child.warehouse.isin(warehouses))
+
+ return query.run(as_dict=True)
+
def prepare_final_data(self):
self.data = []
diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js
index cb771e4994..782ce8110a 100644
--- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js
+++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js
@@ -54,11 +54,11 @@ frappe.query_reports["Job Card Summary"] = {
options: ["", "Open", "Work In Progress", "Completed", "On Hold"]
},
{
- label: __("Sales Orders"),
- fieldname: "sales_order",
+ label: __("Work Orders"),
+ fieldname: "work_order",
fieldtype: "MultiSelectList",
get_data: function(txt) {
- return frappe.db.get_link_options('Sales Order', txt);
+ return frappe.db.get_link_options('Work Order', txt);
}
},
{
diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py
index 5083b7369d..8d72ef1f36 100644
--- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py
+++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py
@@ -36,10 +36,14 @@ def get_data(filters):
"total_time_in_mins",
]
- for field in ["work_order", "workstation", "operation", "status", "company"]:
+ for field in ["work_order", "production_item"]:
if filters.get(field):
query_filters[field] = ("in", filters.get(field))
+ for field in ["workstation", "operation", "status", "company"]:
+ if filters.get(field):
+ query_filters[field] = filters.get(field)
+
data = frappe.get_all("Job Card", fields=fields, filters=query_filters)
if not data:
@@ -85,8 +89,8 @@ def get_chart_data(job_card_details, filters):
open_job_cards.append(periodic_data.get("Open").get(d))
completed.append(periodic_data.get("Completed").get(d))
- datasets.append({"name": "Open", "values": open_job_cards})
- datasets.append({"name": "Completed", "values": completed})
+ datasets.append({"name": _("Open"), "values": open_job_cards})
+ datasets.append({"name": _("Completed"), "values": completed})
chart = {"data": {"labels": labels, "datasets": datasets}, "type": "bar"}
diff --git a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py
index b10e643422..ce8f4f35a3 100644
--- a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py
+++ b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py
@@ -5,6 +5,7 @@ from typing import Dict, List, Tuple
import frappe
from frappe import _
+from frappe.query_builder.functions import Sum
Filters = frappe._dict
Row = frappe._dict
@@ -14,15 +15,50 @@ QueryArgs = Dict[str, str]
def execute(filters: Filters) -> Tuple[Columns, Data]:
+ filters = frappe._dict(filters or {})
columns = get_columns()
data = get_data(filters)
return columns, data
def get_data(filters: Filters) -> Data:
- query_args = get_query_args(filters)
- data = run_query(query_args)
+ wo = frappe.qb.DocType("Work Order")
+ se = frappe.qb.DocType("Stock Entry")
+
+ query = (
+ frappe.qb.from_(wo)
+ .inner_join(se)
+ .on(wo.name == se.work_order)
+ .select(
+ wo.name,
+ wo.status,
+ wo.production_item,
+ wo.qty,
+ wo.produced_qty,
+ wo.process_loss_qty,
+ (wo.produced_qty - wo.process_loss_qty).as_("actual_produced_qty"),
+ Sum(se.total_incoming_value).as_("total_fg_value"),
+ Sum(se.total_outgoing_value).as_("total_rm_value"),
+ )
+ .where(
+ (wo.process_loss_qty > 0)
+ & (wo.company == filters.company)
+ & (se.docstatus == 1)
+ & (se.posting_date.between(filters.from_date, filters.to_date))
+ )
+ .groupby(se.work_order)
+ )
+
+ if "item" in filters:
+ query.where(wo.production_item == filters.item)
+
+ if "work_order" in filters:
+ query.where(wo.name == filters.work_order)
+
+ data = query.run(as_dict=True)
+
update_data_with_total_pl_value(data)
+
return data
@@ -67,54 +103,7 @@ def get_columns() -> Columns:
]
-def get_query_args(filters: Filters) -> QueryArgs:
- query_args = {}
- query_args.update(filters)
- query_args.update(get_filter_conditions(filters))
- return query_args
-
-
-def run_query(query_args: QueryArgs) -> Data:
- return frappe.db.sql(
- """
- SELECT
- wo.name, wo.status, wo.production_item, wo.qty,
- wo.produced_qty, wo.process_loss_qty,
- (wo.produced_qty - wo.process_loss_qty) as actual_produced_qty,
- sum(se.total_incoming_value) as total_fg_value,
- sum(se.total_outgoing_value) as total_rm_value
- FROM
- `tabWork Order` wo INNER JOIN `tabStock Entry` se
- ON wo.name=se.work_order
- WHERE
- process_loss_qty > 0
- AND wo.company = %(company)s
- AND se.docstatus = 1
- AND se.posting_date BETWEEN %(from_date)s AND %(to_date)s
- {item_filter}
- {work_order_filter}
- GROUP BY
- se.work_order
- """.format(
- **query_args
- ),
- query_args,
- as_dict=1,
- )
-
-
def update_data_with_total_pl_value(data: Data) -> None:
for row in data:
value_per_unit_fg = row["total_fg_value"] / row["actual_produced_qty"]
row["total_pl_value"] = row["process_loss_qty"] * value_per_unit_fg
-
-
-def get_filter_conditions(filters: Filters) -> QueryArgs:
- filter_conditions = dict(item_filter="", work_order_filter="")
- if "item" in filters:
- production_item = filters.get("item")
- filter_conditions.update({"item_filter": f"AND wo.production_item='{production_item}'"})
- if "work_order" in filters:
- work_order_name = filters.get("work_order")
- filter_conditions.update({"work_order_filter": f"AND wo.name='{work_order_name}'"})
- return filter_conditions
diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
index 140488820a..109d9ab656 100644
--- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
+++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
@@ -4,42 +4,10 @@
import frappe
from frappe import _
+from pypika import Order
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
-# and bom_no is not null and bom_no !=''
-
-mapper = {
- "Sales Order": {
- "fields": """ item_code as production_item, item_name as production_item_name, stock_uom,
- stock_qty as qty_to_manufacture, `tabSales Order Item`.parent as name, bom_no, warehouse,
- `tabSales Order Item`.delivery_date, `tabSales Order`.base_grand_total """,
- "filters": """`tabSales Order Item`.docstatus = 1 and stock_qty > produced_qty
- and `tabSales Order`.per_delivered < 100.0""",
- },
- "Material Request": {
- "fields": """ item_code as production_item, item_name as production_item_name, stock_uom,
- stock_qty as qty_to_manufacture, `tabMaterial Request Item`.parent as name, bom_no, warehouse,
- `tabMaterial Request Item`.schedule_date """,
- "filters": """`tabMaterial Request`.docstatus = 1 and `tabMaterial Request`.per_ordered < 100
- and `tabMaterial Request`.material_request_type = 'Manufacture' """,
- },
- "Work Order": {
- "fields": """ production_item, item_name as production_item_name, planned_start_date,
- stock_uom, qty as qty_to_manufacture, name, bom_no, fg_warehouse as warehouse """,
- "filters": "docstatus = 1 and status not in ('Completed', 'Stopped')",
- },
-}
-
-order_mapper = {
- "Sales Order": {
- "Delivery Date": "`tabSales Order Item`.delivery_date asc",
- "Total Amount": "`tabSales Order`.base_grand_total desc",
- },
- "Material Request": {"Required Date": "`tabMaterial Request Item`.schedule_date asc"},
- "Work Order": {"Planned Start Date": "planned_start_date asc"},
-}
-
def execute(filters=None):
return ProductionPlanReport(filters).execute_report()
@@ -63,40 +31,81 @@ class ProductionPlanReport(object):
return self.columns, self.data
def get_open_orders(self):
- doctype = (
- "`tabWork Order`"
- if self.filters.based_on == "Work Order"
- else "`tab{doc}`, `tab{doc} Item`".format(doc=self.filters.based_on)
- )
+ doctype, order_by = self.filters.based_on, self.filters.order_by
- filters = mapper.get(self.filters.based_on)["filters"]
- filters = self.prepare_other_conditions(filters, self.filters.based_on)
- order_by = " ORDER BY %s" % (order_mapper[self.filters.based_on][self.filters.order_by])
+ parent = frappe.qb.DocType(doctype)
+ query = None
- self.orders = frappe.db.sql(
- """ SELECT {fields} from {doctype}
- WHERE {filters} {order_by}""".format(
- doctype=doctype,
- filters=filters,
- order_by=order_by,
- fields=mapper.get(self.filters.based_on)["fields"],
- ),
- tuple(self.filters.docnames),
- as_dict=1,
- )
+ if doctype == "Work Order":
+ query = (
+ frappe.qb.from_(parent)
+ .select(
+ parent.production_item,
+ parent.item_name.as_("production_item_name"),
+ parent.planned_start_date,
+ parent.stock_uom,
+ parent.qty.as_("qty_to_manufacture"),
+ parent.name,
+ parent.bom_no,
+ parent.fg_warehouse.as_("warehouse"),
+ )
+ .where(parent.status.notin(["Completed", "Stopped", "Closed"]))
+ )
- def prepare_other_conditions(self, filters, doctype):
- if self.filters.docnames:
- field = "name" if doctype == "Work Order" else "`tab{} Item`.parent".format(doctype)
- filters += " and %s in (%s)" % (field, ",".join(["%s"] * len(self.filters.docnames)))
+ if order_by == "Planned Start Date":
+ query = query.orderby(parent.planned_start_date, order=Order.asc)
- if doctype != "Work Order":
- filters += " and `tab{doc}`.name = `tab{doc} Item`.parent".format(doc=doctype)
+ if self.filters.docnames:
+ query = query.where(parent.name.isin(self.filters.docnames))
+
+ else:
+ child = frappe.qb.DocType(f"{doctype} Item")
+ query = (
+ frappe.qb.from_(parent)
+ .from_(child)
+ .select(
+ child.bom_no,
+ child.stock_uom,
+ child.warehouse,
+ child.parent.as_("name"),
+ child.item_code.as_("production_item"),
+ child.stock_qty.as_("qty_to_manufacture"),
+ child.item_name.as_("production_item_name"),
+ )
+ .where(parent.name == child.parent)
+ )
+
+ if self.filters.docnames:
+ query = query.where(child.parent.isin(self.filters.docnames))
+
+ if doctype == "Sales Order":
+ query = query.select(child.delivery_date, parent.base_grand_total,).where(
+ (child.stock_qty > child.produced_qty)
+ & (parent.per_delivered < 100.0)
+ & (parent.status.notin(["Completed", "Closed"]))
+ )
+
+ if order_by == "Delivery Date":
+ query = query.orderby(child.delivery_date, order=Order.asc)
+ elif order_by == "Total Amount":
+ query = query.orderby(parent.base_grand_total, order=Order.desc)
+
+ elif doctype == "Material Request":
+ query = query.select(child.schedule_date,).where(
+ (parent.per_ordered < 100)
+ & (parent.material_request_type == "Manufacture")
+ & (parent.status != "Stopped")
+ )
+
+ if order_by == "Required Date":
+ query = query.orderby(child.schedule_date, order=Order.asc)
+
+ query = query.where(parent.docstatus == 1)
if self.filters.company:
- filters += " and `tab%s`.company = %s" % (doctype, frappe.db.escape(self.filters.company))
+ query = query.where(parent.company == self.filters.company)
- return filters
+ self.orders = query.run(as_dict=True)
def get_raw_materials(self):
if not self.orders:
@@ -134,29 +143,29 @@ class ProductionPlanReport(object):
bom_nos.append(bom_no)
- bom_doctype = (
+ bom_item_doctype = (
"BOM Explosion Item" if self.filters.include_subassembly_raw_materials else "BOM Item"
)
- qty_field = (
- "qty_consumed_per_unit"
- if self.filters.include_subassembly_raw_materials
- else "(bom_item.qty / bom.quantity)"
- )
+ bom = frappe.qb.DocType("BOM")
+ bom_item = frappe.qb.DocType(bom_item_doctype)
- raw_materials = frappe.db.sql(
- """ SELECT bom_item.parent, bom_item.item_code,
- bom_item.item_name as raw_material_name, {0} as required_qty_per_unit
- FROM
- `tabBOM` as bom, `tab{1}` as bom_item
- WHERE
- bom_item.parent in ({2}) and bom_item.parent = bom.name and bom.docstatus = 1
- """.format(
- qty_field, bom_doctype, ",".join(["%s"] * len(bom_nos))
- ),
- tuple(bom_nos),
- as_dict=1,
- )
+ if self.filters.include_subassembly_raw_materials:
+ qty_field = bom_item.qty_consumed_per_unit
+ else:
+ qty_field = bom_item.qty / bom.quantity
+
+ raw_materials = (
+ frappe.qb.from_(bom)
+ .from_(bom_item)
+ .select(
+ bom_item.parent,
+ bom_item.item_code,
+ bom_item.item_name.as_("raw_material_name"),
+ qty_field.as_("required_qty_per_unit"),
+ )
+ .where((bom_item.parent.isin(bom_nos)) & (bom_item.parent == bom.name) & (bom.docstatus == 1))
+ ).run(as_dict=True)
if not raw_materials:
return
diff --git a/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js b/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js
index b2428e85b7..2fb4ec6791 100644
--- a/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js
+++ b/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js
@@ -50,7 +50,7 @@ frappe.query_reports["Work Order Consumed Materials"] = {
label: __("Status"),
fieldname: "status",
fieldtype: "Select",
- options: ["In Process", "Completed", "Stopped"]
+ options: ["", "In Process", "Completed", "Stopped"]
},
{
label: __("Excess Materials Consumed"),
diff --git a/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.py b/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.py
index 8158bc9a02..14e97d3dd7 100644
--- a/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.py
+++ b/erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.py
@@ -1,6 +1,8 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+from collections import defaultdict
+
import frappe
from frappe import _
@@ -18,7 +20,11 @@ def get_data(report_filters):
filters = get_filter_condition(report_filters)
wo_items = {}
- for d in frappe.get_all("Work Order", filters=filters, fields=fields):
+
+ work_orders = frappe.get_all("Work Order", filters=filters, fields=fields)
+ returned_materials = get_returned_materials(work_orders)
+
+ for d in work_orders:
d.extra_consumed_qty = 0.0
if d.consumed_qty and d.consumed_qty > d.required_qty:
d.extra_consumed_qty = d.consumed_qty - d.required_qty
@@ -39,6 +45,28 @@ def get_data(report_filters):
return data
+def get_returned_materials(work_orders):
+ raw_materials_qty = defaultdict(float)
+
+ raw_materials = frappe.get_all(
+ "Stock Entry",
+ fields=["`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`qty`"],
+ filters=[
+ ["Stock Entry", "is_return", "=", 1],
+ ["Stock Entry Detail", "docstatus", "=", 1],
+ ["Stock Entry", "work_order", "in", [d.name for d in work_orders]],
+ ],
+ )
+
+ for d in raw_materials:
+ raw_materials_qty[d.item_code] += d.qty
+
+ for row in work_orders:
+ row.returned_qty = 0.0
+ if raw_materials_qty.get(row.raw_material_item_code):
+ row.returned_qty = raw_materials_qty.get(row.raw_material_item_code)
+
+
def get_fields():
return [
"`tabWork Order Item`.`parent`",
@@ -65,7 +93,7 @@ def get_filter_condition(report_filters):
for field in ["name", "production_item", "company", "status"]:
value = report_filters.get(field)
if value:
- key = f"`{field}`"
+ key = f"{field}"
filters.update({key: value})
return filters
@@ -112,4 +140,10 @@ def get_columns():
"fieldtype": "Float",
"width": 100,
},
+ {
+ "label": _("Returned Qty"),
+ "fieldname": "returned_qty",
+ "fieldtype": "Float",
+ "width": 100,
+ },
]
diff --git a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
index 063ebba059..998b0e4bcc 100644
--- a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
+++ b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
@@ -4,6 +4,7 @@
import frappe
from frappe import _
+from frappe.query_builder.functions import IfNull
from frappe.utils import cint
@@ -17,70 +18,70 @@ def execute(filters=None):
def get_item_list(wo_list, filters):
out = []
- # Add a row for each item/qty
- for wo_details in wo_list:
- desc = frappe.db.get_value("BOM", wo_details.bom_no, "description")
+ if wo_list:
+ bin = frappe.qb.DocType("Bin")
+ bom = frappe.qb.DocType("BOM")
+ bom_item = frappe.qb.DocType("BOM Item")
- for wo_item_details in frappe.db.get_values(
- "Work Order Item", {"parent": wo_details.name}, ["item_code", "source_warehouse"], as_dict=1
- ):
+ # Add a row for each item/qty
+ for wo_details in wo_list:
+ desc = frappe.db.get_value("BOM", wo_details.bom_no, "description")
- item_list = frappe.db.sql(
- """SELECT
- bom_item.item_code as item_code,
- ifnull(ledger.actual_qty*bom.quantity/bom_item.stock_qty,0) as build_qty
- FROM
- `tabBOM` as bom, `tabBOM Item` AS bom_item
- LEFT JOIN `tabBin` AS ledger
- ON bom_item.item_code = ledger.item_code
- AND ledger.warehouse = ifnull(%(warehouse)s,%(filterhouse)s)
- WHERE
- bom.name = bom_item.parent
- and bom_item.item_code = %(item_code)s
- and bom.name = %(bom)s
- GROUP BY
- bom_item.item_code""",
- {
- "bom": wo_details.bom_no,
- "warehouse": wo_item_details.source_warehouse,
- "filterhouse": filters.warehouse,
- "item_code": wo_item_details.item_code,
- },
- as_dict=1,
- )
+ for wo_item_details in frappe.db.get_values(
+ "Work Order Item", {"parent": wo_details.name}, ["item_code", "source_warehouse"], as_dict=1
+ ):
+ item_list = (
+ frappe.qb.from_(bom)
+ .from_(bom_item)
+ .left_join(bin)
+ .on(
+ (bom_item.item_code == bin.item_code)
+ & (bin.warehouse == IfNull(wo_item_details.source_warehouse, filters.warehouse))
+ )
+ .select(
+ bom_item.item_code.as_("item_code"),
+ IfNull(bin.actual_qty * bom.quantity / bom_item.stock_qty, 0).as_("build_qty"),
+ )
+ .where(
+ (bom.name == bom_item.parent)
+ & (bom_item.item_code == wo_item_details.item_code)
+ & (bom.name == wo_details.bom_no)
+ )
+ .groupby(bom_item.item_code)
+ ).run(as_dict=1)
- stock_qty = 0
- count = 0
- buildable_qty = wo_details.qty
- for item in item_list:
- count = count + 1
- if item.build_qty >= (wo_details.qty - wo_details.produced_qty):
- stock_qty = stock_qty + 1
- elif buildable_qty >= item.build_qty:
- buildable_qty = item.build_qty
+ stock_qty = 0
+ count = 0
+ buildable_qty = wo_details.qty
+ for item in item_list:
+ count = count + 1
+ if item.build_qty >= (wo_details.qty - wo_details.produced_qty):
+ stock_qty = stock_qty + 1
+ elif buildable_qty >= item.build_qty:
+ buildable_qty = item.build_qty
- if count == stock_qty:
- build = "Y"
- else:
- build = "N"
+ if count == stock_qty:
+ build = "Y"
+ else:
+ build = "N"
- row = frappe._dict(
- {
- "work_order": wo_details.name,
- "status": wo_details.status,
- "req_items": cint(count),
- "instock": stock_qty,
- "description": desc,
- "source_warehouse": wo_item_details.source_warehouse,
- "item_code": wo_item_details.item_code,
- "bom_no": wo_details.bom_no,
- "qty": wo_details.qty,
- "buildable_qty": buildable_qty,
- "ready_to_build": build,
- }
- )
+ row = frappe._dict(
+ {
+ "work_order": wo_details.name,
+ "status": wo_details.status,
+ "req_items": cint(count),
+ "instock": stock_qty,
+ "description": desc,
+ "source_warehouse": wo_item_details.source_warehouse,
+ "item_code": wo_item_details.item_code,
+ "bom_no": wo_details.bom_no,
+ "qty": wo_details.qty,
+ "buildable_qty": buildable_qty,
+ "ready_to_build": build,
+ }
+ )
- out.append(row)
+ out.append(row)
return out
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
index 832be2301c..67bd24dd80 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
@@ -13,38 +13,24 @@ frappe.query_reports["Work Order Summary"] = {
reqd: 1
},
{
- fieldname: "fiscal_year",
- label: __("Fiscal Year"),
- fieldtype: "Link",
- options: "Fiscal Year",
- default: frappe.defaults.get_user_default("fiscal_year"),
- reqd: 1,
- on_change: function(query_report) {
- var fiscal_year = query_report.get_values().fiscal_year;
- if (!fiscal_year) {
- return;
- }
- frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
- var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
- frappe.query_report.set_filter_value({
- from_date: fy.year_start_date,
- to_date: fy.year_end_date
- });
- });
- }
+ label: __("Based On"),
+ fieldname:"based_on",
+ fieldtype: "Select",
+ options: "Creation Date\nPlanned Date\nActual Date",
+ default: "Creation Date"
},
{
label: __("From Posting Date"),
fieldname:"from_date",
fieldtype: "Date",
- default: frappe.defaults.get_user_default("year_start_date"),
+ default: frappe.datetime.add_months(frappe.datetime.get_today(), -3),
reqd: 1
},
{
label: __("To Posting Date"),
fieldname:"to_date",
fieldtype: "Date",
- default: frappe.defaults.get_user_default("year_end_date"),
+ default: frappe.datetime.get_today(),
reqd: 1,
},
{
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
index 2368bfdf2c..97f30ef62e 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
@@ -31,6 +31,7 @@ def get_data(filters):
"sales_order",
"production_item",
"qty",
+ "creation",
"produced_qty",
"planned_start_date",
"planned_end_date",
@@ -39,15 +40,25 @@ def get_data(filters):
"lead_time",
]
- for field in ["sales_order", "production_item", "status", "company"]:
+ for field in ["sales_order", "production_item"]:
if filters.get(field):
query_filters[field] = ("in", filters.get(field))
- query_filters["planned_start_date"] = (">=", filters.get("from_date"))
- query_filters["planned_end_date"] = ("<=", filters.get("to_date"))
+ for field in ["status", "company"]:
+ if filters.get(field):
+ query_filters[field] = filters.get(field)
+
+ if filters.get("based_on") == "Planned Date":
+ query_filters["planned_start_date"] = (">=", filters.get("from_date"))
+ query_filters["planned_end_date"] = ("<=", filters.get("to_date"))
+ elif filters.get("based_on") == "Actual Date":
+ query_filters["actual_start_date"] = (">=", filters.get("from_date"))
+ query_filters["actual_end_date"] = ("<=", filters.get("to_date"))
+ else:
+ query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")])
data = frappe.get_all(
- "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc"
+ "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc", debug=1
)
res = []
@@ -83,6 +94,7 @@ def get_chart_based_on_status(data):
for d in data:
status_wise_data[d.status] += 1
+ labels = [_(label) for label in labels]
values = [status_wise_data[label] for label in labels]
chart = {
@@ -95,7 +107,7 @@ def get_chart_based_on_status(data):
def get_chart_based_on_age(data):
- labels = ["0-30 Days", "30-60 Days", "60-90 Days", "90 Above"]
+ labels = [_("0-30 Days"), _("30-60 Days"), _("60-90 Days"), _("90 Above")]
age_wise_data = {"0-30 Days": 0, "30-60 Days": 0, "60-90 Days": 0, "90 Above": 0}
@@ -135,8 +147,8 @@ def get_chart_based_on_qty(data, filters):
pending.append(periodic_data.get("Pending").get(d))
completed.append(periodic_data.get("Completed").get(d))
- datasets.append({"name": "Pending", "values": pending})
- datasets.append({"name": "Completed", "values": completed})
+ datasets.append({"name": _("Pending"), "values": pending})
+ datasets.append({"name": _("Completed"), "values": completed})
chart = {
"data": {"labels": labels, "datasets": datasets},
@@ -208,6 +220,12 @@ def get_columns(filters):
"options": "Sales Order",
"width": 90,
},
+ {
+ "label": _("Created On"),
+ "fieldname": "creation",
+ "fieldtype": "Date",
+ "width": 150,
+ },
{
"label": _("Planned Start Date"),
"fieldname": "planned_start_date",
diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
index 549f5afc70..c25f606060 100644
--- a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
+++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
@@ -73,168 +73,6 @@
"onboard": 0,
"type": "Link"
},
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Bill of Materials",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Item",
- "link_count": 0,
- "link_to": "Item",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "Item",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Bill of Materials",
- "link_count": 0,
- "link_to": "BOM",
- "link_type": "DocType",
- "onboard": 1,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Workstation",
- "link_count": 0,
- "link_to": "Workstation",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Operation",
- "link_count": 0,
- "link_to": "Operation",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Routing",
- "link_count": 0,
- "link_to": "Routing",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Work Order",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Production Planning Report",
- "link_count": 0,
- "link_to": "Production Planning Report",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Work Order",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Work Order Summary",
- "link_count": 0,
- "link_to": "Work Order Summary",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Quality Inspection",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Quality Inspection Summary",
- "link_count": 0,
- "link_to": "Quality Inspection Summary",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Downtime Entry",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Downtime Analysis",
- "link_count": 0,
- "link_to": "Downtime Analysis",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Job Card",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Job Card Summary",
- "link_count": 0,
- "link_to": "Job Card Summary",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "BOM",
- "hidden": 0,
- "is_query_report": 1,
- "label": "BOM Search",
- "link_count": 0,
- "link_to": "BOM Search",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "BOM",
- "hidden": 0,
- "is_query_report": 1,
- "label": "BOM Stock Report",
- "link_count": 0,
- "link_to": "BOM Stock Report",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "Work Order",
- "hidden": 0,
- "is_query_report": 1,
- "label": "Production Analytics",
- "link_count": 0,
- "link_to": "Production Analytics",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "BOM",
- "hidden": 0,
- "is_query_report": 1,
- "label": "BOM Operations Time",
- "link_count": 0,
- "link_to": "BOM Operations Time",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
{
"hidden": 0,
"is_query_report": 0,
@@ -400,9 +238,181 @@
"link_type": "Report",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Bill of Materials",
+ "link_count": 15,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Item",
+ "link_count": 0,
+ "link_to": "Item",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Item",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Bill of Materials",
+ "link_count": 0,
+ "link_to": "BOM",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Workstation Type",
+ "link_count": 0,
+ "link_to": "Workstation Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Workstation",
+ "link_count": 0,
+ "link_to": "Workstation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Operation",
+ "link_count": 0,
+ "link_to": "Operation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Work Order",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Routing",
+ "link_count": 0,
+ "link_to": "Routing",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Work Order",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Production Planning Report",
+ "link_count": 0,
+ "link_to": "Production Planning Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Quality Inspection",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Work Order Summary",
+ "link_count": 0,
+ "link_to": "Work Order Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Downtime Entry",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Quality Inspection Summary",
+ "link_count": 0,
+ "link_to": "Quality Inspection Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Job Card",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Downtime Analysis",
+ "link_count": 0,
+ "link_to": "Downtime Analysis",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "BOM",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Job Card Summary",
+ "link_count": 0,
+ "link_to": "Job Card Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "BOM",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "BOM Search",
+ "link_count": 0,
+ "link_to": "BOM Search",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Work Order",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "BOM Stock Report",
+ "link_count": 0,
+ "link_to": "BOM Stock Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "BOM",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Production Analytics",
+ "link_count": 0,
+ "link_to": "Production Analytics",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "BOM Operations Time",
+ "link_count": 0,
+ "link_to": "BOM Operations Time",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2022-06-15 15:18:57.062935",
+ "modified": "2022-11-14 14:53:34.616862",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 1d5f5d7f99..2abd65b1b5 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -169,7 +169,6 @@ erpnext.patches.v13_0.delete_old_sales_reports
execute:frappe.delete_doc_if_exists("DocType", "Bank Reconciliation")
execute:frappe.reload_doc("regional", "doctype", "e_invoice_settings")
erpnext.patches.v13_0.loyalty_points_entry_for_pos_invoice #22-07-2020
-erpnext.patches.v12_0.add_taxjar_integration_field
erpnext.patches.v12_0.fix_percent_complete_for_projects
erpnext.patches.v13_0.delete_report_requested_items_to_order
erpnext.patches.v12_0.update_item_tax_template_company
@@ -195,7 +194,6 @@ erpnext.patches.v13_0.update_project_template_tasks
erpnext.patches.v13_0.convert_qi_parameter_to_link_field
erpnext.patches.v13_0.add_naming_series_to_old_projects # 1-02-2021
erpnext.patches.v13_0.update_payment_terms_outstanding
-erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl
erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes
erpnext.patches.v13_0.update_vehicle_no_reqd_condition
erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings
@@ -228,7 +226,6 @@ erpnext.patches.v13_0.migrate_stripe_api
erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries
execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings")
execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category")
-erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
erpnext.patches.v13_0.set_operation_time_based_on_operating_cost
erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021
erpnext.patches.v13_0.fix_invoice_statuses
@@ -253,21 +250,23 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory
erpnext.patches.v13_0.update_category_in_ltds_certificate
erpnext.patches.v13_0.fetch_thumbnail_in_website_items
erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
-erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022
erpnext.patches.v14_0.migrate_crm_settings
-erpnext.patches.v13_0.rename_ksa_qr_field
erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty
-erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021
erpnext.patches.v13_0.agriculture_deprecation_warning
erpnext.patches.v13_0.hospitality_deprecation_warning
erpnext.patches.v13_0.update_asset_quantity_field
erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.enable_provisional_accounting
erpnext.patches.v13_0.non_profit_deprecation_warning
-erpnext.patches.v13_0.enable_ksa_vat_docs #1
erpnext.patches.v13_0.show_india_localisation_deprecation_warning
erpnext.patches.v13_0.show_hr_payroll_deprecation_warning
erpnext.patches.v13_0.reset_corrupt_defaults
+erpnext.patches.v13_0.create_accounting_dimensions_for_asset_repair
+erpnext.patches.v15_0.delete_taxjar_doctypes
+erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets
+erpnext.patches.v14_0.update_reference_due_date_in_journal_entry
+erpnext.patches.v15_0.saudi_depreciation_warning
+erpnext.patches.v15_0.delete_saudi_doctypes
[post_model_sync]
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
@@ -290,6 +289,7 @@ erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v14_0.delete_amazon_mws_doctype
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
erpnext.patches.v13_0.update_accounts_in_loan_docs
+erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl
erpnext.patches.v14_0.update_batch_valuation_flag
erpnext.patches.v14_0.delete_non_profit_doctypes
erpnext.patches.v13_0.add_cost_center_in_loans
@@ -304,8 +304,25 @@ erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
execute:frappe.delete_doc("DocType", "Naming Series")
erpnext.patches.v13_0.job_card_status_on_hold
erpnext.patches.v14_0.copy_is_subcontracted_value_to_is_old_subcontracting_flow
-erpnext.patches.v14_0.migrate_gl_to_payment_ledger
erpnext.patches.v14_0.crm_ux_cleanup
+erpnext.patches.v14_0.migrate_existing_lead_notes_as_per_the_new_format
erpnext.patches.v14_0.remove_india_localisation # 14-07-2022
erpnext.patches.v13_0.fix_number_and_frequency_for_monthly_depreciation
-erpnext.patches.v14_0.remove_hr_and_payroll_modules # 20-07-2022
\ No newline at end of file
+erpnext.patches.v14_0.remove_hr_and_payroll_modules # 20-07-2022
+erpnext.patches.v14_0.fix_crm_no_of_employees
+erpnext.patches.v14_0.create_accounting_dimensions_in_subcontracting_doctypes
+erpnext.patches.v14_0.fix_subcontracting_receipt_gl_entries
+erpnext.patches.v13_0.update_schedule_type_in_loans
+erpnext.patches.v13_0.drop_unused_sle_index_parts
+erpnext.patches.v14_0.create_accounting_dimensions_for_asset_capitalization
+erpnext.patches.v14_0.update_partial_tds_fields
+erpnext.patches.v14_0.create_incoterms_and_migrate_shipment
+erpnext.patches.v14_0.setup_clear_repost_logs
+erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request
+erpnext.patches.v14_0.update_entry_type_for_journal_entry
+erpnext.patches.v14_0.change_autoname_for_tax_withheld_vouchers
+erpnext.patches.v14_0.set_pick_list_status
+erpnext.patches.v15_0.update_asset_value_for_manual_depr_entries
+# below 2 migration patches should always run last
+erpnext.patches.v14_0.migrate_gl_to_payment_ledger
+erpnext.patches.v14_0.migrate_remarks_from_gl_to_payment_ledger
diff --git a/erpnext/patches/v11_0/update_sales_partner_type.py b/erpnext/patches/v11_0/update_sales_partner_type.py
index 2d37fd69b1..72fd424b24 100644
--- a/erpnext/patches/v11_0/update_sales_partner_type.py
+++ b/erpnext/patches/v11_0/update_sales_partner_type.py
@@ -1,16 +1,17 @@
import frappe
-from frappe import _
def execute():
- from erpnext.setup.setup_wizard.operations.install_fixtures import default_sales_partner_type
+ from erpnext.setup.setup_wizard.operations.install_fixtures import read_lines
frappe.reload_doc("selling", "doctype", "sales_partner_type")
frappe.local.lang = frappe.db.get_default("lang") or "en"
+ default_sales_partner_type = read_lines("sales_partner_type.txt")
+
for s in default_sales_partner_type:
- insert_sales_partner_type(_(s))
+ insert_sales_partner_type(s)
# get partner type in existing forms (customized)
# and create a document if not created
diff --git a/erpnext/patches/v12_0/add_taxjar_integration_field.py b/erpnext/patches/v12_0/add_taxjar_integration_field.py
deleted file mode 100644
index 9217384b81..0000000000
--- a/erpnext/patches/v12_0/add_taxjar_integration_field.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import frappe
-
-from erpnext.regional.united_states.setup import make_custom_fields
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "United States"})
- if not company:
- return
-
- make_custom_fields()
diff --git a/erpnext/patches/v13_0/add_doctype_to_sla.py b/erpnext/patches/v13_0/add_doctype_to_sla.py
index 5f5974f65d..2d3b0de5b5 100644
--- a/erpnext/patches/v13_0/add_doctype_to_sla.py
+++ b/erpnext/patches/v13_0/add_doctype_to_sla.py
@@ -14,7 +14,8 @@ def execute():
for sla in frappe.get_all("Service Level Agreement"):
agreement = frappe.get_doc("Service Level Agreement", sla.name)
- agreement.document_type = "Issue"
+ agreement.db_set("document_type", "Issue")
+ agreement.reload()
agreement.apply_sla_for_resolution = 1
agreement.append("sla_fulfilled_on", {"status": "Resolved"})
agreement.append("sla_fulfilled_on", {"status": "Closed"})
diff --git a/erpnext/patches/v13_0/create_accounting_dimensions_for_asset_repair.py b/erpnext/patches/v13_0/create_accounting_dimensions_for_asset_repair.py
new file mode 100644
index 0000000000..61a5c86386
--- /dev/null
+++ b/erpnext/patches/v13_0/create_accounting_dimensions_for_asset_repair.py
@@ -0,0 +1,29 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+
+
+def execute():
+ accounting_dimensions = frappe.db.get_all(
+ "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"]
+ )
+
+ if not accounting_dimensions:
+ return
+
+ for d in accounting_dimensions:
+ doctype = "Asset Repair"
+ field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname})
+
+ if field:
+ continue
+
+ df = {
+ "fieldname": d.fieldname,
+ "label": d.label,
+ "fieldtype": "Link",
+ "options": d.document_type,
+ "insert_after": "accounting_dimensions_section",
+ }
+
+ create_custom_field(doctype, df, ignore_validate=True)
+ frappe.clear_cache(doctype=doctype)
diff --git a/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py b/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py
deleted file mode 100644
index 093463a12e..0000000000
--- a/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import frappe
-
-from erpnext.regional.saudi_arabia.setup import make_custom_fields
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "Saudi Arabia"})
- if not company:
- return
-
- make_custom_fields()
diff --git a/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py b/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py
deleted file mode 100644
index 5cbd0b5fcb..0000000000
--- a/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-
-from erpnext.erpnext_integrations.doctype.taxjar_settings.taxjar_settings import add_permissions
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "United States"}, fields=["name"])
- if not company:
- return
-
- TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value(
- "TaxJar Settings", "taxjar_create_transactions"
- )
- TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
- TAXJAR_SANDBOX_MODE = frappe.db.get_single_value("TaxJar Settings", "is_sandbox")
-
- if not TAXJAR_CREATE_TRANSACTIONS and not TAXJAR_CALCULATE_TAX and not TAXJAR_SANDBOX_MODE:
- return
-
- custom_fields = {
- "Sales Invoice Item": [
- dict(
- fieldname="product_tax_category",
- fieldtype="Link",
- insert_after="description",
- options="Product Tax Category",
- label="Product Tax Category",
- fetch_from="item_code.product_tax_category",
- ),
- dict(
- fieldname="tax_collectable",
- fieldtype="Currency",
- insert_after="net_amount",
- label="Tax Collectable",
- read_only=1,
- options="currency",
- ),
- dict(
- fieldname="taxable_amount",
- fieldtype="Currency",
- insert_after="tax_collectable",
- label="Taxable Amount",
- read_only=1,
- options="currency",
- ),
- ],
- "Item": [
- dict(
- fieldname="product_tax_category",
- fieldtype="Link",
- insert_after="item_group",
- options="Product Tax Category",
- label="Product Tax Category",
- )
- ],
- "TaxJar Settings": [
- dict(
- fieldname="company",
- fieldtype="Link",
- insert_after="configuration",
- options="Company",
- label="Company",
- )
- ],
- }
- create_custom_fields(custom_fields, update=True)
- add_permissions()
- frappe.enqueue(
- "erpnext.erpnext_integrations.doctype.taxjar_settings.taxjar_settings.add_product_tax_categories",
- now=True,
- )
diff --git a/erpnext/patches/v13_0/delete_old_purchase_reports.py b/erpnext/patches/v13_0/delete_old_purchase_reports.py
index 987f53f37c..60621fbc9c 100644
--- a/erpnext/patches/v13_0/delete_old_purchase_reports.py
+++ b/erpnext/patches/v13_0/delete_old_purchase_reports.py
@@ -17,10 +17,11 @@ def execute():
for report in reports_to_delete:
if frappe.db.exists("Report", report):
+ delete_links_from_desktop_icons(report)
delete_auto_email_reports(report)
check_and_delete_linked_reports(report)
- frappe.delete_doc("Report", report)
+ frappe.delete_doc("Report", report, force=True)
def delete_auto_email_reports(report):
@@ -28,3 +29,10 @@ def delete_auto_email_reports(report):
auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"])
for auto_email_report in auto_email_reports:
frappe.delete_doc("Auto Email Report", auto_email_report[0])
+
+
+def delete_links_from_desktop_icons(report):
+ """Check for one or multiple Desktop Icons and delete"""
+ desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"])
+ for desktop_icon in desktop_icons:
+ frappe.delete_doc("Desktop Icon", desktop_icon[0], force=True)
diff --git a/erpnext/patches/v13_0/delete_old_sales_reports.py b/erpnext/patches/v13_0/delete_old_sales_reports.py
index b31c9d17d7..1b53da755c 100644
--- a/erpnext/patches/v13_0/delete_old_sales_reports.py
+++ b/erpnext/patches/v13_0/delete_old_sales_reports.py
@@ -16,18 +16,18 @@ def execute():
delete_auto_email_reports(report)
check_and_delete_linked_reports(report)
- frappe.delete_doc("Report", report)
+ frappe.delete_doc("Report", report, force=True)
def delete_auto_email_reports(report):
"""Check for one or multiple Auto Email Reports and delete"""
auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"])
for auto_email_report in auto_email_reports:
- frappe.delete_doc("Auto Email Report", auto_email_report[0])
+ frappe.delete_doc("Auto Email Report", auto_email_report[0], force=True)
def delete_links_from_desktop_icons(report):
"""Check for one or multiple Desktop Icons and delete"""
desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"])
for desktop_icon in desktop_icons:
- frappe.delete_doc("Desktop Icon", desktop_icon[0])
+ frappe.delete_doc("Desktop Icon", desktop_icon[0], force=True)
diff --git a/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py b/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py
deleted file mode 100644
index 84b6c37dd9..0000000000
--- a/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Copyright (c) 2020, Wahni Green Technologies and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-
-from erpnext.regional.saudi_arabia.setup import add_print_formats
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "Saudi Arabia"})
- if company:
- add_print_formats()
- return
-
- if frappe.db.exists("DocType", "Print Format"):
- frappe.reload_doc("regional", "print_format", "ksa_vat_invoice", force=True)
- frappe.reload_doc("regional", "print_format", "ksa_pos_invoice", force=True)
- for d in ("KSA VAT Invoice", "KSA POS Invoice"):
- frappe.db.set_value("Print Format", d, "disabled", 1)
diff --git a/erpnext/patches/v13_0/drop_unused_sle_index_parts.py b/erpnext/patches/v13_0/drop_unused_sle_index_parts.py
new file mode 100644
index 0000000000..fa8a98ce16
--- /dev/null
+++ b/erpnext/patches/v13_0/drop_unused_sle_index_parts.py
@@ -0,0 +1,14 @@
+import frappe
+
+from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import on_doctype_update
+
+
+def execute():
+ try:
+ frappe.db.sql_ddl("ALTER TABLE `tabStock Ledger Entry` DROP INDEX `posting_sort_index`")
+ except Exception:
+ frappe.log_error("Failed to drop index")
+ return
+
+ # Recreate indexes
+ on_doctype_update()
diff --git a/erpnext/patches/v13_0/enable_ksa_vat_docs.py b/erpnext/patches/v13_0/enable_ksa_vat_docs.py
deleted file mode 100644
index 4adf4d71db..0000000000
--- a/erpnext/patches/v13_0/enable_ksa_vat_docs.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import frappe
-
-from erpnext.regional.saudi_arabia.setup import add_permissions, add_print_formats
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "Saudi Arabia"})
- if not company:
- return
-
- add_print_formats()
- add_permissions()
diff --git a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
index 75a5477be8..c0d715063a 100644
--- a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
+++ b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
@@ -7,6 +7,7 @@ from erpnext.stock.stock_ledger import update_entries_after
def execute():
doctypes_to_reload = [
+ ("setup", "company"),
("stock", "repost_item_valuation"),
("stock", "stock_entry_detail"),
("stock", "purchase_receipt_item"),
diff --git a/erpnext/patches/v13_0/rename_ksa_qr_field.py b/erpnext/patches/v13_0/rename_ksa_qr_field.py
deleted file mode 100644
index e4b91412ee..0000000000
--- a/erpnext/patches/v13_0/rename_ksa_qr_field.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (c) 2020, Wahni Green Technologies and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-from frappe.model.utils.rename_field import rename_field
-
-
-def execute():
- company = frappe.get_all("Company", filters={"country": "Saudi Arabia"})
- if not company:
- return
-
- if frappe.db.exists("DocType", "Sales Invoice"):
- frappe.reload_doc("accounts", "doctype", "sales_invoice", force=True)
-
- # rename_field method assumes that the field already exists or the doc is synced
- if not frappe.db.has_column("Sales Invoice", "ksa_einv_qr"):
- create_custom_fields(
- {
- "Sales Invoice": [
- dict(
- fieldname="ksa_einv_qr",
- label="KSA E-Invoicing QR",
- fieldtype="Attach Image",
- read_only=1,
- no_copy=1,
- hidden=1,
- )
- ]
- }
- )
-
- if frappe.db.has_column("Sales Invoice", "qr_code"):
- rename_field("Sales Invoice", "qr_code", "ksa_einv_qr")
- frappe.delete_doc_if_exists("Custom Field", "Sales Invoice-qr_code")
diff --git a/erpnext/patches/v13_0/update_exchange_rate_settings.py b/erpnext/patches/v13_0/update_exchange_rate_settings.py
index ed11c627d9..130a7bf7df 100644
--- a/erpnext/patches/v13_0/update_exchange_rate_settings.py
+++ b/erpnext/patches/v13_0/update_exchange_rate_settings.py
@@ -4,7 +4,5 @@ from erpnext.setup.install import setup_currency_exchange
def execute():
- frappe.reload_doc("accounts", "doctype", "currency_exchange_settings_result")
- frappe.reload_doc("accounts", "doctype", "currency_exchange_settings_details")
frappe.reload_doc("accounts", "doctype", "currency_exchange_settings")
setup_currency_exchange()
diff --git a/erpnext/patches/v13_0/update_old_loans.py b/erpnext/patches/v13_0/update_old_loans.py
index a1d40b739e..0bd3fcdec4 100644
--- a/erpnext/patches/v13_0/update_old_loans.py
+++ b/erpnext/patches/v13_0/update_old_loans.py
@@ -100,6 +100,7 @@ def execute():
"mode_of_payment": loan.mode_of_payment,
"loan_account": loan.loan_account,
"payment_account": loan.payment_account,
+ "disbursement_account": loan.payment_account,
"interest_income_account": loan.interest_income_account,
"penalty_income_account": loan.penalty_income_account,
},
@@ -190,6 +191,7 @@ def create_loan_type(loan, loan_type_name, penalty_account):
loan_type_doc.company = loan.company
loan_type_doc.mode_of_payment = loan.mode_of_payment
loan_type_doc.payment_account = loan.payment_account
+ loan_type_doc.disbursement_account = loan.payment_account
loan_type_doc.loan_account = loan.loan_account
loan_type_doc.interest_income_account = loan.interest_income_account
loan_type_doc.penalty_income_account = penalty_account
diff --git a/erpnext/patches/v13_0/update_schedule_type_in_loans.py b/erpnext/patches/v13_0/update_schedule_type_in_loans.py
new file mode 100644
index 0000000000..e5b5f64360
--- /dev/null
+++ b/erpnext/patches/v13_0/update_schedule_type_in_loans.py
@@ -0,0 +1,14 @@
+import frappe
+
+
+def execute():
+ loan = frappe.qb.DocType("Loan")
+ loan_type = frappe.qb.DocType("Loan Type")
+
+ frappe.qb.update(loan_type).set(
+ loan_type.repayment_schedule_type, "Monthly as per repayment start date"
+ ).where(loan_type.is_term_loan == 1).run()
+
+ frappe.qb.update(loan).set(
+ loan.repayment_schedule_type, "Monthly as per repayment start date"
+ ).where(loan.is_term_loan == 1).run()
diff --git a/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py b/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py
new file mode 100644
index 0000000000..e20ba73dbb
--- /dev/null
+++ b/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py
@@ -0,0 +1,12 @@
+import frappe
+
+
+def execute():
+ if (
+ frappe.db.sql(
+ """select data_type FROM information_schema.columns
+ where column_name = 'name' and table_name = 'tabTax Withheld Vouchers'"""
+ )[0][0]
+ == "bigint"
+ ):
+ frappe.db.change_column_type("Tax Withheld Vouchers", "name", "varchar(140)")
diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_for_asset_capitalization.py b/erpnext/patches/v14_0/create_accounting_dimensions_for_asset_capitalization.py
new file mode 100644
index 0000000000..09e20a9d79
--- /dev/null
+++ b/erpnext/patches/v14_0/create_accounting_dimensions_for_asset_capitalization.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+
+
+def execute():
+ accounting_dimensions = frappe.db.get_all(
+ "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"]
+ )
+
+ if not accounting_dimensions:
+ return
+
+ doctype = "Asset Capitalization"
+
+ for d in accounting_dimensions:
+ field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname})
+
+ if field:
+ continue
+
+ df = {
+ "fieldname": d.fieldname,
+ "label": d.label,
+ "fieldtype": "Link",
+ "options": d.document_type,
+ "insert_after": "accounting_dimensions_section",
+ }
+
+ create_custom_field(doctype, df, ignore_validate=True)
+
+ frappe.clear_cache(doctype=doctype)
diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py b/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py
new file mode 100644
index 0000000000..bede419ad2
--- /dev/null
+++ b/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+
+
+def execute():
+ accounting_dimensions = frappe.db.get_all(
+ "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"]
+ )
+
+ if not accounting_dimensions:
+ return
+
+ doctype = "Payment Request"
+
+ for d in accounting_dimensions:
+ field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname})
+
+ if field:
+ continue
+
+ df = {
+ "fieldname": d.fieldname,
+ "label": d.label,
+ "fieldtype": "Link",
+ "options": d.document_type,
+ "insert_after": "accounting_dimensions_section",
+ }
+
+ create_custom_field(doctype, df, ignore_validate=True)
+
+ frappe.clear_cache(doctype=doctype)
diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_in_subcontracting_doctypes.py b/erpnext/patches/v14_0/create_accounting_dimensions_in_subcontracting_doctypes.py
new file mode 100644
index 0000000000..b349c07f6d
--- /dev/null
+++ b/erpnext/patches/v14_0/create_accounting_dimensions_in_subcontracting_doctypes.py
@@ -0,0 +1,47 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+
+
+def execute():
+ accounting_dimensions = frappe.db.get_all(
+ "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"]
+ )
+
+ if not accounting_dimensions:
+ return
+
+ count = 1
+ for d in accounting_dimensions:
+
+ if count % 2 == 0:
+ insert_after_field = "dimension_col_break"
+ else:
+ insert_after_field = "accounting_dimensions_section"
+
+ for doctype in [
+ "Subcontracting Order",
+ "Subcontracting Order Item",
+ "Subcontracting Receipt",
+ "Subcontracting Receipt Item",
+ ]:
+
+ field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname})
+
+ if field:
+ continue
+
+ df = {
+ "fieldname": d.fieldname,
+ "label": d.label,
+ "fieldtype": "Link",
+ "options": d.document_type,
+ "insert_after": insert_after_field,
+ }
+
+ try:
+ create_custom_field(doctype, df, ignore_validate=True)
+ frappe.clear_cache(doctype=doctype)
+ except Exception:
+ pass
+
+ count += 1
diff --git a/erpnext/patches/v14_0/create_incoterms_and_migrate_shipment.py b/erpnext/patches/v14_0/create_incoterms_and_migrate_shipment.py
new file mode 100644
index 0000000000..6e1e09ad14
--- /dev/null
+++ b/erpnext/patches/v14_0/create_incoterms_and_migrate_shipment.py
@@ -0,0 +1,31 @@
+import frappe
+
+from erpnext.setup.doctype.incoterm.incoterm import create_incoterms
+
+
+def execute():
+ create_incoterms()
+ migrate_shipments()
+
+
+def migrate_shipments():
+ if not frappe.db.count("Shipment"):
+ return
+
+ OLD_VALUES = [
+ "EXW (Ex Works)",
+ "FCA (Free Carrier)",
+ "FOB (Free On Board)",
+ "FAS (Free Alongside Ship)",
+ "CPT (Carriage Paid To)",
+ "CIP (Carriage and Insurance Paid to)",
+ "CFR (Cost and Freight)",
+ "DPU (Delivered At Place Unloaded)",
+ "DAP (Delivered At Place)",
+ "DDP (Delivered Duty Paid)",
+ ]
+ shipment = frappe.qb.DocType("Shipment")
+ for old_value in OLD_VALUES:
+ frappe.qb.update(shipment).set(shipment.incoterm, old_value[:3]).where(
+ shipment.incoterm == old_value
+ ).run()
diff --git a/erpnext/patches/v14_0/fix_crm_no_of_employees.py b/erpnext/patches/v14_0/fix_crm_no_of_employees.py
new file mode 100644
index 0000000000..268eb95732
--- /dev/null
+++ b/erpnext/patches/v14_0/fix_crm_no_of_employees.py
@@ -0,0 +1,26 @@
+import frappe
+
+
+def execute():
+ options = {
+ "11-20": "11-50",
+ "21-30": "11-50",
+ "31-100": "51-200",
+ "101-500": "201-500",
+ "500-1000": "501-1000",
+ ">1000": "1000+",
+ }
+
+ for doctype in ("Lead", "Opportunity", "Prospect"):
+ frappe.reload_doctype(doctype)
+ for key, value in options.items():
+ frappe.db.sql(
+ """
+ update `tab{doctype}`
+ set no_of_employees = %s
+ where no_of_employees = %s
+ """.format(
+ doctype=doctype
+ ),
+ (value, key),
+ )
diff --git a/erpnext/patches/v14_0/fix_subcontracting_receipt_gl_entries.py b/erpnext/patches/v14_0/fix_subcontracting_receipt_gl_entries.py
new file mode 100644
index 0000000000..159c6dc82d
--- /dev/null
+++ b/erpnext/patches/v14_0/fix_subcontracting_receipt_gl_entries.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+
+from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
+ get_data,
+)
+
+
+def execute():
+ data = []
+
+ for company in frappe.db.get_list("Company", pluck="name"):
+ data += get_data(
+ frappe._dict(
+ {
+ "company": company,
+ }
+ )
+ )
+
+ if data:
+ for d in data:
+ if d and d.get("voucher_type") == "Subcontracting Receipt":
+ doc = frappe.new_doc("Repost Item Valuation")
+ doc.voucher_type = d.get("voucher_type")
+ doc.voucher_no = d.get("voucher_no")
+ doc.save()
+ doc.submit()
diff --git a/erpnext/patches/v14_0/migrate_cost_center_allocations.py b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
index 3bd26933ba..48f4e6d989 100644
--- a/erpnext/patches/v14_0/migrate_cost_center_allocations.py
+++ b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
@@ -18,9 +18,11 @@ def create_new_cost_center_allocation_records(cc_allocations):
cca = frappe.new_doc("Cost Center Allocation")
cca.main_cost_center = main_cc
cca.valid_from = today()
+ cca._skip_from_date_validation = True
for child_cc, percentage in allocations.items():
cca.append("allocation_percentages", ({"cost_center": child_cc, "percentage": percentage}))
+
cca.save()
cca.submit()
diff --git a/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py b/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py
new file mode 100644
index 0000000000..ec72527552
--- /dev/null
+++ b/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py
@@ -0,0 +1,23 @@
+import frappe
+from frappe.utils import cstr, strip_html
+
+
+def execute():
+ for doctype in ("Lead", "Prospect", "Opportunity"):
+ if not frappe.db.has_column(doctype, "notes"):
+ continue
+
+ dt = frappe.qb.DocType(doctype)
+ records = (
+ frappe.qb.from_(dt)
+ .select(dt.name, dt.notes, dt.modified_by, dt.modified)
+ .where(dt.notes.isnotnull() & dt.notes != "")
+ ).run(as_dict=True)
+
+ for d in records:
+ if strip_html(cstr(d.notes)).strip():
+ doc = frappe.get_doc(doctype, d.name)
+ doc.append("notes", {"note": d.notes, "added_by": d.modified_by, "added_on": d.modified})
+ doc.update_child_table("notes")
+
+ frappe.db.sql_ddl(f"alter table `tab{doctype}` drop column `notes`")
diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
index e15aa4a1f4..853a99a489 100644
--- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
+++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
@@ -2,7 +2,8 @@ import frappe
from frappe import qb
from frappe.query_builder import Case, CustomFunction
from frappe.query_builder.custom import ConstantColumn
-from frappe.query_builder.functions import IfNull
+from frappe.query_builder.functions import Count, IfNull
+from frappe.utils import flt
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_dimensions,
@@ -17,9 +18,9 @@ def create_accounting_dimension_fields():
make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"])
-def generate_name_for_payment_ledger_entries(gl_entries):
- for index, entry in enumerate(gl_entries, 1):
- entry.name = index
+def generate_name_for_payment_ledger_entries(gl_entries, start):
+ for index, entry in enumerate(gl_entries, 0):
+ entry.name = start + index
def get_columns():
@@ -81,6 +82,14 @@ def insert_chunk_into_payment_ledger(insert_query, gl_entries):
def execute():
+ """
+ Description:
+ Migrate records from `tabGL Entry` to `tabPayment Ledger Entry`.
+ Patch is non-resumable. if patch failed or is terminatted abnormally, clear 'tabPayment Ledger Entry' table manually before re-running. Re-running is safe only during V13->V14 update.
+
+ Note: Post successful migration to V14, re-running is NOT-SAFE and SHOULD NOT be attempted.
+ """
+
if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"):
# create accounting dimension fields in Payment Ledger
create_accounting_dimension_fields()
@@ -89,52 +98,90 @@ def execute():
account = qb.DocType("Account")
ifelse = CustomFunction("IF", ["condition", "then", "else"])
- gl_entries = (
- qb.from_(gl)
- .inner_join(account)
- .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"])))
- .select(
- gl.star,
- ConstantColumn(1).as_("docstatus"),
- account.account_type.as_("account_type"),
- IfNull(
- ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type
- ).as_("against_voucher_type"),
- IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_(
- "against_voucher_no"
- ),
- # convert debit/credit to amount
- Case()
- .when(account.account_type == "Receivable", gl.debit - gl.credit)
- .else_(gl.credit - gl.debit)
- .as_("amount"),
- # convert debit/credit in account currency to amount in account currency
- Case()
- .when(
- account.account_type == "Receivable",
- gl.debit_in_account_currency - gl.credit_in_account_currency,
- )
- .else_(gl.credit_in_account_currency - gl.debit_in_account_currency)
- .as_("amount_in_account_currency"),
- )
- .where(gl.is_cancelled == 0)
- .orderby(gl.creation)
- .run(as_dict=True)
+ # Get Records Count
+ accounts = (
+ qb.from_(account)
+ .select(account.name)
+ .where((account.account_type == "Receivable") | (account.account_type == "Payable"))
+ .orderby(account.name)
)
+ un_processed = (
+ qb.from_(gl)
+ .select(Count(gl.name))
+ .where((gl.is_cancelled == 0) & (gl.account.isin(accounts)))
+ .run()
+ )[0][0]
- # primary key(name) for payment ledger records
- generate_name_for_payment_ledger_entries(gl_entries)
+ if un_processed:
+ print(f"Migrating {un_processed} GL Entries to Payment Ledger")
- # split data into chunks
- chunk_size = 1000
- try:
- for i in range(0, len(gl_entries), chunk_size):
- insert_query = build_insert_query()
- insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size])
- frappe.db.commit()
- except Exception as err:
- frappe.db.rollback()
- ple = qb.DocType("Payment Ledger Entry")
- qb.from_(ple).delete().where(ple.docstatus >= 0).run()
- frappe.db.commit()
- raise err
+ processed = 0
+ last_update_percent = 0
+ batch_size = 5000
+ last_name = None
+
+ while True:
+ if last_name:
+ where_clause = gl.name.gt(last_name) & (gl.is_cancelled == 0)
+ else:
+ where_clause = gl.is_cancelled == 0
+
+ gl_entries = (
+ qb.from_(gl)
+ .inner_join(account)
+ .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"])))
+ .select(
+ gl.star,
+ ConstantColumn(1).as_("docstatus"),
+ account.account_type.as_("account_type"),
+ IfNull(
+ ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type
+ ).as_("against_voucher_type"),
+ IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_(
+ "against_voucher_no"
+ ),
+ # convert debit/credit to amount
+ Case()
+ .when(account.account_type == "Receivable", gl.debit - gl.credit)
+ .else_(gl.credit - gl.debit)
+ .as_("amount"),
+ # convert debit/credit in account currency to amount in account currency
+ Case()
+ .when(
+ account.account_type == "Receivable",
+ gl.debit_in_account_currency - gl.credit_in_account_currency,
+ )
+ .else_(gl.credit_in_account_currency - gl.debit_in_account_currency)
+ .as_("amount_in_account_currency"),
+ )
+ .where(where_clause)
+ .orderby(gl.name)
+ .limit(batch_size)
+ .run(as_dict=True)
+ )
+
+ if gl_entries:
+ last_name = gl_entries[-1].name
+
+ # primary key(name) for payment ledger records
+ generate_name_for_payment_ledger_entries(gl_entries, processed)
+
+ try:
+ insert_query = build_insert_query()
+ insert_chunk_into_payment_ledger(insert_query, gl_entries)
+ frappe.db.commit()
+
+ processed += len(gl_entries)
+
+ # Progress message
+ percent = flt((processed / un_processed) * 100, 2)
+ if percent - last_update_percent > 1:
+ print(f"{percent}% ({processed}) records processed")
+ last_update_percent = percent
+
+ except Exception as err:
+ print("Migration Failed. Clear `tabPayment Ledger Entry` table before re-running")
+ raise err
+ else:
+ break
+ print(f"{processed} records have been sucessfully migrated")
diff --git a/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py
new file mode 100644
index 0000000000..9d216c4028
--- /dev/null
+++ b/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py
@@ -0,0 +1,98 @@
+import frappe
+from frappe import qb
+from frappe.query_builder import CustomFunction
+from frappe.query_builder.functions import Count, IfNull
+from frappe.utils import flt
+
+
+def execute():
+ """
+ Migrate 'remarks' field from 'tabGL Entry' to 'tabPayment Ledger Entry'
+ """
+
+ if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"):
+
+ gle = qb.DocType("GL Entry")
+ ple = qb.DocType("Payment Ledger Entry")
+
+ # Get empty PLE records
+ un_processed = (
+ qb.from_(ple).select(Count(ple.name)).where((ple.remarks.isnull()) & (ple.delinked == 0)).run()
+ )[0][0]
+
+ if un_processed:
+ print(f"Remarks for {un_processed} Payment Ledger records will be updated from GL Entry")
+
+ ifelse = CustomFunction("IF", ["condition", "then", "else"])
+
+ processed = 0
+ last_percent_update = 0
+ batch_size = 1000
+ last_name = None
+
+ while True:
+ if last_name:
+ where_clause = (ple.name.gt(last_name)) & (ple.remarks.isnull()) & (ple.delinked == 0)
+ else:
+ where_clause = (ple.remarks.isnull()) & (ple.delinked == 0)
+
+ # results are deterministic
+ names = (
+ qb.from_(ple).select(ple.name).where(where_clause).orderby(ple.name).limit(batch_size).run()
+ )
+
+ if names:
+ last_name = names[-1][0]
+
+ pl_entries = (
+ qb.from_(ple)
+ .left_join(gle)
+ .on(
+ (ple.account == gle.account)
+ & (ple.party_type == gle.party_type)
+ & (ple.party == gle.party)
+ & (ple.voucher_type == gle.voucher_type)
+ & (ple.voucher_no == gle.voucher_no)
+ & (
+ ple.against_voucher_type
+ == IfNull(
+ ifelse(gle.against_voucher_type == "", None, gle.against_voucher_type), gle.voucher_type
+ )
+ )
+ & (
+ ple.against_voucher_no
+ == IfNull(ifelse(gle.against_voucher == "", None, gle.against_voucher), gle.voucher_no)
+ )
+ & (ple.company == gle.company)
+ & (
+ ((ple.account_type == "Receivable") & (ple.amount == (gle.debit - gle.credit)))
+ | (ple.account_type == "Payable") & (ple.amount == (gle.credit - gle.debit))
+ )
+ & (gle.remarks.notnull())
+ & (gle.is_cancelled == 0)
+ )
+ .select(ple.name)
+ .distinct()
+ .select(
+ gle.remarks.as_("gle_remarks"),
+ )
+ .where(ple.name.isin(names))
+ .run(as_dict=True)
+ )
+
+ if pl_entries:
+ for entry in pl_entries:
+ query = qb.update(ple).set(ple.remarks, entry.gle_remarks).where((ple.name == entry.name))
+ query.run()
+
+ frappe.db.commit()
+
+ processed += len(pl_entries)
+ percentage = flt((processed / un_processed) * 100, 2)
+ if percentage - last_percent_update > 1:
+ print(f"{percentage}% ({processed}) PLE records updated")
+ last_percent_update = percentage
+
+ else:
+ break
+ print("Remarks succesfully migrated")
diff --git a/erpnext/patches/v14_0/set_pick_list_status.py b/erpnext/patches/v14_0/set_pick_list_status.py
new file mode 100644
index 0000000000..eea5745c23
--- /dev/null
+++ b/erpnext/patches/v14_0/set_pick_list_status.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
+
+
+import frappe
+from pypika.terms import ExistsCriterion
+
+
+def execute():
+ pl = frappe.qb.DocType("Pick List")
+ se = frappe.qb.DocType("Stock Entry")
+ dn = frappe.qb.DocType("Delivery Note")
+
+ (
+ frappe.qb.update(pl).set(
+ pl.status,
+ (
+ frappe.qb.terms.Case()
+ .when(pl.docstatus == 0, "Draft")
+ .when(pl.docstatus == 2, "Cancelled")
+ .else_("Completed")
+ ),
+ )
+ ).run()
+
+ (
+ frappe.qb.update(pl)
+ .set(pl.status, "Open")
+ .where(
+ (
+ ExistsCriterion(
+ frappe.qb.from_(se).select(se.name).where((se.docstatus == 1) & (se.pick_list == pl.name))
+ )
+ | ExistsCriterion(
+ frappe.qb.from_(dn).select(dn.name).where((dn.docstatus == 1) & (dn.pick_list == pl.name))
+ )
+ ).negate()
+ & (pl.docstatus == 1)
+ )
+ ).run()
diff --git a/erpnext/patches/v14_0/setup_clear_repost_logs.py b/erpnext/patches/v14_0/setup_clear_repost_logs.py
new file mode 100644
index 0000000000..be9ddcab7a
--- /dev/null
+++ b/erpnext/patches/v14_0/setup_clear_repost_logs.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: MIT. See LICENSE
+
+from erpnext.setup.install import setup_log_settings
+
+
+def execute():
+ setup_log_settings()
diff --git a/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py
new file mode 100644
index 0000000000..bce9255557
--- /dev/null
+++ b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py
@@ -0,0 +1,18 @@
+import frappe
+
+
+def execute():
+ """
+ Update Propery Setters for Journal Entry with new 'Entry Type'
+ """
+ new_voucher_type = "Exchange Gain Or Loss"
+ prop_setter = frappe.db.get_list(
+ "Property Setter",
+ filters={"doc_type": "Journal Entry", "field_name": "voucher_type", "property": "options"},
+ )
+ if prop_setter:
+ property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name"))
+
+ if new_voucher_type not in property_setter_doc.value.split("\n"):
+ property_setter_doc.value += "\n" + new_voucher_type
+ property_setter_doc.save()
diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py
index 076de52619..b803e9fa2d 100644
--- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py
+++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py
@@ -1,3 +1,4 @@
+import click
import frappe
from frappe.utils import flt
@@ -16,6 +17,19 @@ def execute():
for opportunity in opportunities:
company_currency = erpnext.get_company_currency(opportunity.company)
+ if opportunity.currency is None or opportunity.currency == "":
+ opportunity.currency = company_currency
+ frappe.db.set_value(
+ "Opportunity",
+ opportunity.name,
+ {"currency": opportunity.currency},
+ update_modified=False,
+ )
+ click.secho(
+ f' Opportunity `{opportunity.name}` has no currency set. Setting it to company currency as default: `{opportunity.currency}`"\n',
+ fg="yellow",
+ )
+
# base total and total will be 0 only since item table did not have amount field earlier
if opportunity.currency != company_currency:
conversion_rate = get_exchange_rate(opportunity.currency, company_currency)
diff --git a/erpnext/patches/v14_0/update_partial_tds_fields.py b/erpnext/patches/v14_0/update_partial_tds_fields.py
new file mode 100644
index 0000000000..5ccc2dc3aa
--- /dev/null
+++ b/erpnext/patches/v14_0/update_partial_tds_fields.py
@@ -0,0 +1,45 @@
+import frappe
+from frappe.utils import nowdate
+
+from erpnext.accounts.utils import FiscalYearError, get_fiscal_year
+
+
+def execute():
+ # Only do for current fiscal year, no need to repost for all years
+ for company in frappe.get_all("Company"):
+ try:
+ fiscal_year_details = get_fiscal_year(date=nowdate(), company=company.name, as_dict=True)
+
+ purchase_invoice = frappe.qb.DocType("Purchase Invoice")
+
+ frappe.qb.update(purchase_invoice).set(
+ purchase_invoice.tax_withholding_net_total, purchase_invoice.net_total
+ ).set(
+ purchase_invoice.base_tax_withholding_net_total, purchase_invoice.base_net_total
+ ).where(
+ purchase_invoice.company == company.name
+ ).where(
+ purchase_invoice.apply_tds == 1
+ ).where(
+ purchase_invoice.posting_date >= fiscal_year_details.year_start_date
+ ).where(
+ purchase_invoice.docstatus == 1
+ ).run()
+
+ purchase_order = frappe.qb.DocType("Purchase Order")
+
+ frappe.qb.update(purchase_order).set(
+ purchase_order.tax_withholding_net_total, purchase_order.net_total
+ ).set(
+ purchase_order.base_tax_withholding_net_total, purchase_order.base_net_total
+ ).where(
+ purchase_order.company == company.name
+ ).where(
+ purchase_order.apply_tds == 1
+ ).where(
+ purchase_order.transaction_date >= fiscal_year_details.year_start_date
+ ).where(
+ purchase_order.docstatus == 1
+ ).run()
+ except FiscalYearError:
+ pass
diff --git a/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py b/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py
new file mode 100644
index 0000000000..70003125a5
--- /dev/null
+++ b/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py
@@ -0,0 +1,12 @@
+import frappe
+
+
+def execute():
+ if frappe.db.get_value("Journal Entry Account", {"reference_due_date": ""}):
+ frappe.db.sql(
+ """
+ UPDATE `tabJournal Entry Account`
+ SET reference_due_date = NULL
+ WHERE reference_due_date = ''
+ """
+ )
diff --git a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py
new file mode 100644
index 0000000000..371ecbc8c1
--- /dev/null
+++ b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py
@@ -0,0 +1,76 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc("assets", "doctype", "Asset Depreciation Schedule")
+
+ assets = get_details_of_draft_or_submitted_depreciable_assets()
+
+ for asset in assets:
+ finance_book_rows = get_details_of_asset_finance_books_rows(asset.name)
+
+ for fb_row in finance_book_rows:
+ asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule")
+
+ asset_depr_schedule_doc.set_draft_asset_depr_schedule_details(asset, fb_row)
+
+ asset_depr_schedule_doc.insert()
+
+ if asset.docstatus == 1:
+ asset_depr_schedule_doc.submit()
+
+ update_depreciation_schedules(asset.name, asset_depr_schedule_doc.name, fb_row.idx)
+
+
+def get_details_of_draft_or_submitted_depreciable_assets():
+ asset = frappe.qb.DocType("Asset")
+
+ records = (
+ frappe.qb.from_(asset)
+ .select(asset.name, asset.opening_accumulated_depreciation, asset.docstatus)
+ .where(asset.calculate_depreciation == 1)
+ .where(asset.docstatus < 2)
+ ).run(as_dict=True)
+
+ return records
+
+
+def get_details_of_asset_finance_books_rows(asset_name):
+ afb = frappe.qb.DocType("Asset Finance Book")
+
+ records = (
+ frappe.qb.from_(afb)
+ .select(
+ afb.finance_book,
+ afb.idx,
+ afb.depreciation_method,
+ afb.total_number_of_depreciations,
+ afb.frequency_of_depreciation,
+ afb.rate_of_depreciation,
+ afb.expected_value_after_useful_life,
+ )
+ .where(afb.parent == asset_name)
+ ).run(as_dict=True)
+
+ return records
+
+
+def update_depreciation_schedules(asset_name, asset_depr_schedule_name, fb_row_idx):
+ ds = frappe.qb.DocType("Depreciation Schedule")
+
+ depr_schedules = (
+ frappe.qb.from_(ds)
+ .select(ds.name)
+ .where((ds.parent == asset_name) & (ds.finance_book_id == str(fb_row_idx)))
+ .orderby(ds.idx)
+ ).run(as_dict=True)
+
+ for idx, depr_schedule in enumerate(depr_schedules, start=1):
+ (
+ frappe.qb.update(ds)
+ .set(ds.idx, idx)
+ .set(ds.parent, asset_depr_schedule_name)
+ .set(ds.parentfield, "depreciation_schedule")
+ .set(ds.parenttype, "Asset Depreciation Schedule")
+ .where(ds.name == depr_schedule.name)
+ ).run()
diff --git a/erpnext/patches/v15_0/delete_saudi_doctypes.py b/erpnext/patches/v15_0/delete_saudi_doctypes.py
new file mode 100644
index 0000000000..371e335290
--- /dev/null
+++ b/erpnext/patches/v15_0/delete_saudi_doctypes.py
@@ -0,0 +1,25 @@
+import click
+import frappe
+
+
+def execute():
+ if "ksa" in frappe.get_installed_apps():
+ return
+
+ doctypes = ["KSA VAT Setting", "KSA VAT Purchase Account", "KSA VAT Sales Account"]
+ for doctype in doctypes:
+ frappe.delete_doc("DocType", doctype, ignore_missing=True)
+
+ print_formats = ["KSA POS Invoice", "KSA VAT Invoice"]
+ for print_format in print_formats:
+ frappe.delete_doc("Print Format", print_format, ignore_missing=True, force=True)
+
+ reports = ["KSA VAT"]
+ for report in reports:
+ frappe.delete_doc("Report", report, ignore_missing=True, force=True)
+
+ click.secho(
+ "Region Saudi Arabia(KSA) is moved to a separate app"
+ "Please install the app to continue using the module: https://github.com/8848digital/KSA",
+ fg="yellow",
+ )
diff --git a/erpnext/patches/v15_0/delete_taxjar_doctypes.py b/erpnext/patches/v15_0/delete_taxjar_doctypes.py
new file mode 100644
index 0000000000..13adf41f55
--- /dev/null
+++ b/erpnext/patches/v15_0/delete_taxjar_doctypes.py
@@ -0,0 +1,17 @@
+import click
+import frappe
+
+
+def execute():
+ if "taxjar_integration" in frappe.get_installed_apps():
+ return
+
+ doctypes = ["TaxJar Settings", "TaxJar Nexus", "Product Tax Category"]
+ for doctype in doctypes:
+ frappe.delete_doc("DocType", doctype, ignore_missing=True)
+
+ click.secho(
+ "Taxjar Integration is moved to a separate app"
+ "Please install the app to continue using the module: https://github.com/frappe/taxjar_integration",
+ fg="yellow",
+ )
diff --git a/erpnext/patches/v15_0/saudi_depreciation_warning.py b/erpnext/patches/v15_0/saudi_depreciation_warning.py
new file mode 100644
index 0000000000..6af8efe5ff
--- /dev/null
+++ b/erpnext/patches/v15_0/saudi_depreciation_warning.py
@@ -0,0 +1,12 @@
+import click
+import frappe
+
+
+def execute():
+ if "ksa" in frappe.get_installed_apps():
+ return
+ click.secho(
+ "Region Saudi Arabia(KSA) is moved to a separate app\n"
+ "Please install the app to continue using the KSA Features: https://github.com/8848digital/KSA",
+ fg="yellow",
+ )
diff --git a/erpnext/patches/v15_0/update_asset_value_for_manual_depr_entries.py b/erpnext/patches/v15_0/update_asset_value_for_manual_depr_entries.py
new file mode 100644
index 0000000000..5d7b5cf19c
--- /dev/null
+++ b/erpnext/patches/v15_0/update_asset_value_for_manual_depr_entries.py
@@ -0,0 +1,38 @@
+import frappe
+from frappe.query_builder.functions import IfNull, Sum
+
+
+def execute():
+ asset = frappe.qb.DocType("Asset")
+ gle = frappe.qb.DocType("GL Entry")
+ aca = frappe.qb.DocType("Asset Category Account")
+ company = frappe.qb.DocType("Company")
+
+ asset_total_depr_value_map = (
+ frappe.qb.from_(gle)
+ .join(asset)
+ .on(gle.against_voucher == asset.name)
+ .join(aca)
+ .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company))
+ .join(company)
+ .on(company.name == asset.company)
+ .select(Sum(gle.debit).as_("value"), asset.name.as_("asset_name"))
+ .where(
+ gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account)
+ )
+ .where(gle.debit != 0)
+ .where(gle.is_cancelled == 0)
+ .where(asset.docstatus == 1)
+ .where(asset.calculate_depreciation == 0)
+ .groupby(asset.name)
+ )
+
+ frappe.qb.update(asset).join(asset_total_depr_value_map).on(
+ asset_total_depr_value_map.asset_name == asset.name
+ ).set(
+ asset.value_after_depreciation, asset.value_after_depreciation - asset_total_depr_value_map.value
+ ).where(
+ asset.docstatus == 1
+ ).where(
+ asset.calculate_depreciation == 0
+ ).run()
diff --git a/erpnext/portal/doctype/homepage_section/test_homepage_section.py b/erpnext/portal/doctype/homepage_section/test_homepage_section.py
index 27b0169c59..3df56e67f6 100644
--- a/erpnext/portal/doctype/homepage_section/test_homepage_section.py
+++ b/erpnext/portal/doctype/homepage_section/test_homepage_section.py
@@ -10,58 +10,6 @@ from frappe.website.serve import get_response
class TestHomepageSection(unittest.TestCase):
- def test_homepage_section_card(self):
- try:
- frappe.get_doc(
- {
- "doctype": "Homepage Section",
- "name": "Card Section",
- "section_based_on": "Cards",
- "section_cards": [
- {
- "title": "Card 1",
- "subtitle": "Subtitle 1",
- "content": "This is test card 1",
- "route": "/card-1",
- },
- {
- "title": "Card 2",
- "subtitle": "Subtitle 2",
- "content": "This is test card 2",
- "image": "test.jpg",
- },
- ],
- "no_of_columns": 3,
- }
- ).insert(ignore_if_duplicate=True)
- except frappe.DuplicateEntryError:
- pass
-
- set_request(method="GET", path="home")
- response = get_response()
-
- self.assertEqual(response.status_code, 200)
-
- html = frappe.safe_decode(response.get_data())
-
- soup = BeautifulSoup(html, "html.parser")
- sections = soup.find("main").find_all("section")
- self.assertEqual(len(sections), 3)
-
- homepage_section = sections[2]
- self.assertEqual(homepage_section.h3.text, "Card Section")
-
- cards = homepage_section.find_all(class_="card")
-
- self.assertEqual(len(cards), 2)
- self.assertEqual(cards[0].h5.text, "Card 1")
- self.assertEqual(cards[0].a["href"], "/card-1")
- self.assertEqual(cards[1].p.text, "Subtitle 2")
- self.assertEqual(cards[1].find(class_="website-image-lazy")["data-src"], "test.jpg")
-
- # cleanup
- frappe.db.rollback()
-
def test_homepage_section_custom_html(self):
frappe.get_doc(
{
diff --git a/erpnext/portal/utils.py b/erpnext/portal/utils.py
index 7be8c5df18..c8b03e678b 100644
--- a/erpnext/portal/utils.py
+++ b/erpnext/portal/utils.py
@@ -102,7 +102,7 @@ def create_party_contact(doctype, fullname, user, party_name):
contact = frappe.new_doc("Contact")
contact.update({"first_name": fullname, "email_id": user})
contact.append("links", dict(link_doctype=doctype, link_name=party_name))
- contact.append("email_ids", dict(email_id=user))
+ contact.append("email_ids", dict(email_id=user, is_primary=True))
contact.flags.ignore_mandatory = True
contact.insert(ignore_permissions=True)
diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js
index 4f19bbd516..f366f77556 100644
--- a/erpnext/projects/doctype/project/project.js
+++ b/erpnext/projects/doctype/project/project.js
@@ -20,7 +20,7 @@ frappe.ui.form.on("Project", {
onload: function (frm) {
const so = frm.get_docfield("sales_order");
so.get_route_options_for_new_doc = () => {
- if (frm.is_new()) return;
+ if (frm.is_new()) return {};
return {
"customer": frm.doc.customer,
"project_name": frm.doc.name
@@ -152,6 +152,7 @@ function open_form(frm, doctype, child_doctype, parentfield) {
new_child_doc.parentfield = parentfield;
new_child_doc.parenttype = doctype;
new_doc[parentfield] = [new_child_doc];
+ new_doc.project = frm.doc.name;
frappe.ui.form.make_quick_entry(doctype, null, null, new_doc);
});
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 37d98ad8ea..ba7aa85082 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -408,7 +408,7 @@
"depends_on": "eval:(doc.frequency == \"Daily\" && doc.collect_progress == true)",
"fieldname": "daily_time_to_send",
"fieldtype": "Time",
- "label": "Time to send"
+ "label": "Daily Time to send"
},
{
"depends_on": "eval:(doc.frequency == \"Weekly\" && doc.collect_progress == true)",
@@ -421,7 +421,7 @@
"depends_on": "eval:(doc.frequency == \"Weekly\" && doc.collect_progress == true)",
"fieldname": "weekly_time_to_send",
"fieldtype": "Time",
- "label": "Time to send"
+ "label": "Weekly Time to send"
},
{
"fieldname": "column_break_45",
@@ -451,7 +451,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2022-06-23 16:45:06.108499",
+ "modified": "2023-02-14 04:54:25.819620",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -497,4 +497,4 @@
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
-}
+}
\ No newline at end of file
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index e78e4b6577..7d80ac1cb7 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -7,18 +7,16 @@ from email_reply_parser import EmailReplyParser
from frappe import _
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
+from frappe.query_builder import Interval
+from frappe.query_builder.functions import Count, CurDate, Date, UnixTimestamp
from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today
from erpnext import get_default_company
-from erpnext.controllers.employee_boarding_controller import update_employee_boarding_status
from erpnext.controllers.queries import get_filters_cond
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
class Project(Document):
- def get_feed(self):
- return "{0}: {1}".format(_(self.status), frappe.safe_decode(self.project_name))
-
def onload(self):
self.set_onload(
"activity_summary",
@@ -43,7 +41,8 @@ class Project(Document):
self.send_welcome_email()
self.update_costing()
self.update_percent_complete()
- update_employee_boarding_status(self)
+ self.validate_from_to_dates("expected_start_date", "expected_end_date")
+ self.validate_from_to_dates("actual_start_date", "actual_end_date")
def copy_from_template(self):
"""
@@ -145,7 +144,6 @@ class Project(Document):
def update_project(self):
"""Called externally by Task"""
self.update_percent_complete()
- update_employee_boarding_status(self)
self.update_costing()
self.db_update()
@@ -301,17 +299,19 @@ class Project(Document):
user.welcome_email_sent = 1
-def get_timeline_data(doctype, name):
+def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
"""Return timeline for attendance"""
+
+ timesheet_detail = frappe.qb.DocType("Timesheet Detail")
+
return dict(
- frappe.db.sql(
- """select unix_timestamp(from_time), count(*)
- from `tabTimesheet Detail` where project=%s
- and from_time > date_sub(curdate(), interval 1 year)
- and docstatus < 2
- group by date(from_time)""",
- name,
- )
+ frappe.qb.from_(timesheet_detail)
+ .select(UnixTimestamp(timesheet_detail.from_time), Count("*"))
+ .where(timesheet_detail.project == name)
+ .where(timesheet_detail.from_time > CurDate() - Interval(years=1))
+ .where(timesheet_detail.docstatus < 2)
+ .groupby(Date(timesheet_detail.from_time))
+ .run()
)
@@ -379,7 +379,7 @@ def get_users_for_project(doctype, txt, searchfield, start, page_len, filters):
{fcond} {mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
- (case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end)
+ (case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end),
idx desc,
name, full_name
limit %(page_len)s offset %(start)s""".format(
diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py
index fa507854a6..ce3ae4fc7c 100755
--- a/erpnext/projects/doctype/task/task.py
+++ b/erpnext/projects/doctype/task/task.py
@@ -9,6 +9,7 @@ from frappe import _, throw
from frappe.desk.form.assign_to import clear, close_all_assignments
from frappe.model.mapper import get_mapped_doc
from frappe.utils import add_days, cstr, date_diff, flt, get_link_to_form, getdate, today
+from frappe.utils.data import format_date
from frappe.utils.nestedset import NestedSet
@@ -16,16 +17,9 @@ class CircularReferenceError(frappe.ValidationError):
pass
-class EndDateCannotBeGreaterThanProjectEndDateError(frappe.ValidationError):
- pass
-
-
class Task(NestedSet):
nsm_parent_field = "parent_task"
- def get_feed(self):
- return "{0}: {1}".format(_(self.status), self.subject)
-
def get_customer_details(self):
cust = frappe.db.sql("select customer_name from `tabCustomer` where name=%s", self.customer)
if cust:
@@ -34,8 +28,6 @@ class Task(NestedSet):
def validate(self):
self.validate_dates()
- self.validate_parent_expected_end_date()
- self.validate_parent_project_dates()
self.validate_progress()
self.validate_status()
self.update_depends_on()
@@ -43,51 +35,42 @@ class Task(NestedSet):
self.validate_completed_on()
def validate_dates(self):
- if (
- self.exp_start_date
- and self.exp_end_date
- and getdate(self.exp_start_date) > getdate(self.exp_end_date)
- ):
- frappe.throw(
- _("{0} can not be greater than {1}").format(
- frappe.bold("Expected Start Date"), frappe.bold("Expected End Date")
- )
- )
-
- if (
- self.act_start_date
- and self.act_end_date
- and getdate(self.act_start_date) > getdate(self.act_end_date)
- ):
- frappe.throw(
- _("{0} can not be greater than {1}").format(
- frappe.bold("Actual Start Date"), frappe.bold("Actual End Date")
- )
- )
+ self.validate_from_to_dates("exp_start_date", "exp_end_date")
+ self.validate_from_to_dates("act_start_date", "act_end_date")
+ self.validate_parent_expected_end_date()
+ self.validate_parent_project_dates()
def validate_parent_expected_end_date(self):
- if self.parent_task:
- parent_exp_end_date = frappe.db.get_value("Task", self.parent_task, "exp_end_date")
- if parent_exp_end_date and getdate(self.get("exp_end_date")) > getdate(parent_exp_end_date):
- frappe.throw(
- _(
- "Expected End Date should be less than or equal to parent task's Expected End Date {0}."
- ).format(getdate(parent_exp_end_date))
- )
+ if not self.parent_task or not self.exp_end_date:
+ return
+
+ parent_exp_end_date = frappe.db.get_value("Task", self.parent_task, "exp_end_date")
+ if not parent_exp_end_date:
+ return
+
+ if getdate(self.exp_end_date) > getdate(parent_exp_end_date):
+ frappe.throw(
+ _(
+ "Expected End Date should be less than or equal to parent task's Expected End Date {0}."
+ ).format(format_date(parent_exp_end_date)),
+ frappe.exceptions.InvalidDates,
+ )
def validate_parent_project_dates(self):
if not self.project or frappe.flags.in_test:
return
- expected_end_date = frappe.db.get_value("Project", self.project, "expected_end_date")
-
- if expected_end_date:
- validate_project_dates(
- getdate(expected_end_date), self, "exp_start_date", "exp_end_date", "Expected"
- )
- validate_project_dates(
- getdate(expected_end_date), self, "act_start_date", "act_end_date", "Actual"
- )
+ if project_end_date := frappe.db.get_value("Project", self.project, "expected_end_date"):
+ project_end_date = getdate(project_end_date)
+ for fieldname in ("exp_start_date", "exp_end_date", "act_start_date", "act_end_date"):
+ task_date = self.get(fieldname)
+ if task_date and date_diff(project_end_date, getdate(task_date)) < 0:
+ frappe.throw(
+ _("Task's {0} cannot be after Project's Expected End Date.").format(
+ _(self.meta.get_label(fieldname))
+ ),
+ frappe.exceptions.InvalidDates,
+ )
def validate_status(self):
if self.is_template and self.status != "Template":
@@ -97,7 +80,7 @@ class Task(NestedSet):
if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"):
frappe.throw(
_(
- "Cannot complete task {0} as its dependant task {1} are not ccompleted / cancelled."
+ "Cannot complete task {0} as its dependant task {1} are not completed / cancelled."
).format(frappe.bold(self.name), frappe.bold(d.task))
)
@@ -398,15 +381,3 @@ def add_multiple_tasks(data, parent):
def on_doctype_update():
frappe.db.add_index("Task", ["lft", "rgt"])
-
-
-def validate_project_dates(project_end_date, task, task_start, task_end, actual_or_expected_date):
- if task.get(task_start) and date_diff(project_end_date, getdate(task.get(task_start))) < 0:
- frappe.throw(
- _("Task's {0} Start Date cannot be after Project's End Date.").format(actual_or_expected_date)
- )
-
- if task.get(task_end) and date_diff(project_end_date, getdate(task.get(task_end))) < 0:
- frappe.throw(
- _("Task's {0} End Date cannot be after Project's End Date.").format(actual_or_expected_date)
- )
diff --git a/erpnext/projects/doctype/task_type/task_type.json b/erpnext/projects/doctype/task_type/task_type.json
index 3254444a48..b04264e9c7 100644
--- a/erpnext/projects/doctype/task_type/task_type.json
+++ b/erpnext/projects/doctype/task_type/task_type.json
@@ -1,127 +1,70 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
+ "actions": [],
"autoname": "Prompt",
- "beta": 0,
"creation": "2019-04-19 15:04:05.317138",
- "custom": 0,
- "docstatus": 0,
"doctype": "DocType",
- "document_type": "",
- "editable_grid": 0,
"engine": "InnoDB",
+ "field_order": [
+ "weight",
+ "description"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "weight",
"fieldtype": "Float",
- "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": "Weight",
- "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
+ "label": "Weight"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fetch_if_empty": 0,
"fieldname": "description",
"fieldtype": "Small Text",
- "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": "Description",
- "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
+ "label": "Description"
}
],
- "has_web_view": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2019-04-19 15:31:48.080164",
+ "links": [],
+ "modified": "2022-08-29 17:46:41.342979",
"modified_by": "Administrator",
"module": "Projects",
"name": "Task Type",
- "name_case": "",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
- "set_user_permissions": 0,
"share": 1,
- "submit": 0,
"write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Projects Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Projects User",
+ "share": 1
}
],
"quick_entry": 1,
- "read_only": 0,
- "show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "ASC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index e098c3e3c4..828a55e7bc 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -161,6 +161,37 @@ class TestTimesheet(unittest.TestCase):
to_time = timesheet.time_logs[0].to_time
self.assertEqual(to_time, add_to_date(from_time, hours=2, as_datetime=True))
+ def test_per_billed_hours(self):
+ """If amounts are 0, per_billed should be calculated based on hours."""
+ ts = frappe.new_doc("Timesheet")
+ ts.total_billable_amount = 0
+ ts.total_billed_amount = 0
+ ts.total_billable_hours = 2
+
+ ts.total_billed_hours = 0.5
+ ts.calculate_percentage_billed()
+ self.assertEqual(ts.per_billed, 25)
+
+ ts.total_billed_hours = 2
+ ts.calculate_percentage_billed()
+ self.assertEqual(ts.per_billed, 100)
+
+ def test_per_billed_amount(self):
+ """If amounts are > 0, per_billed should be calculated based on amounts, regardless of hours."""
+ ts = frappe.new_doc("Timesheet")
+ ts.total_billable_hours = 2
+ ts.total_billed_hours = 1
+ ts.total_billable_amount = 200
+ ts.total_billed_amount = 50
+ ts.calculate_percentage_billed()
+ self.assertEqual(ts.per_billed, 25)
+
+ ts.total_billed_hours = 3
+ ts.total_billable_amount = 200
+ ts.total_billed_amount = 200
+ ts.calculate_percentage_billed()
+ self.assertEqual(ts.per_billed, 100)
+
def make_timesheet(
employee,
diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js
index e1486de18c..a376bf46a5 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.js
+++ b/erpnext/projects/doctype/timesheet/timesheet.js
@@ -92,18 +92,26 @@ frappe.ui.form.on("Timesheet", {
frm.fields_dict["time_logs"].grid.toggle_enable("billing_hours", false);
frm.fields_dict["time_logs"].grid.toggle_enable("is_billable", false);
}
+
+ let filters = {
+ "status": "Open"
+ };
+
+ if (frm.doc.customer) {
+ filters["customer"] = frm.doc.customer;
+ }
+
+ frm.set_query('parent_project', function(doc) {
+ return {
+ filters: filters
+ };
+ });
+
frm.trigger('setup_filters');
frm.trigger('set_dynamic_field_label');
},
customer: function(frm) {
- frm.set_query('parent_project', function(doc) {
- return {
- filters: {
- "customer": doc.customer
- }
- };
- });
frm.set_query('project', 'time_logs', function(doc) {
return {
filters: {
diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json
index 0cce129034..468300661a 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.json
+++ b/erpnext/projects/doctype/timesheet/timesheet.json
@@ -282,21 +282,21 @@
{
"fieldname": "base_total_costing_amount",
"fieldtype": "Currency",
- "label": "Total Costing Amount",
+ "label": "Base Total Costing Amount",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_total_billable_amount",
"fieldtype": "Currency",
- "label": "Total Billable Amount",
+ "label": "Base Total Billable Amount",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_total_billed_amount",
"fieldtype": "Currency",
- "label": "Total Billed Amount",
+ "label": "Base Total Billed Amount",
"print_hide": 1,
"read_only": 1
},
@@ -311,10 +311,11 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-06-15 22:08:53.930200",
+ "modified": "2023-02-14 04:55:41.735991",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -388,5 +389,6 @@
],
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"title_field": "title"
}
\ No newline at end of file
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index b9bb37a05c..d482a46053 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -25,12 +25,18 @@ class Timesheet(Document):
def validate(self):
self.set_status()
self.validate_dates()
+ self.calculate_hours()
self.validate_time_logs()
self.update_cost()
self.calculate_total_amounts()
self.calculate_percentage_billed()
self.set_dates()
+ def calculate_hours(self):
+ for row in self.time_logs:
+ if row.to_time and row.from_time:
+ row.hours = time_diff_in_hours(row.to_time, row.from_time)
+
def calculate_total_amounts(self):
self.total_hours = 0.0
self.total_billable_hours = 0.0
@@ -58,6 +64,8 @@ class Timesheet(Document):
self.per_billed = 0
if self.total_billed_amount > 0 and self.total_billable_amount > 0:
self.per_billed = (self.total_billed_amount * 100) / self.total_billable_amount
+ elif self.total_billed_hours > 0 and self.total_billable_hours > 0:
+ self.per_billed = (self.total_billed_hours * 100) / self.total_billable_hours
def update_billing_hours(self, args):
if args.is_billable:
@@ -381,6 +389,9 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
"timesheets",
{
"time_sheet": timesheet.name,
+ "project_name": time_log.project_name,
+ "from_time": time_log.from_time,
+ "to_time": time_log.to_time,
"billing_hours": time_log.billing_hours,
"billing_amount": time_log.billing_amount,
"timesheet_detail": time_log.name,
diff --git a/erpnext/projects/report/project_summary/project_summary.py b/erpnext/projects/report/project_summary/project_summary.py
index 606c0c2d81..7a35fd236a 100644
--- a/erpnext/projects/report/project_summary/project_summary.py
+++ b/erpnext/projects/report/project_summary/project_summary.py
@@ -91,9 +91,9 @@ def get_chart_data(data):
"data": {
"labels": labels[:30],
"datasets": [
- {"name": "Overdue", "values": overdue[:30]},
- {"name": "Completed", "values": completed[:30]},
- {"name": "Total Tasks", "values": total[:30]},
+ {"name": _("Overdue"), "values": overdue[:30]},
+ {"name": _("Completed"), "values": completed[:30]},
+ {"name": _("Total Tasks"), "values": total[:30]},
],
},
"type": "bar",
diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json
index 1253649e49..4bdb1db387 100644
--- a/erpnext/projects/workspace/projects/projects.json
+++ b/erpnext/projects/workspace/projects/projects.json
@@ -5,7 +5,7 @@
"label": "Open Projects"
}
],
- "content": "[{\"type\":\"chart\",\"data\":{\"chart_name\":\"Open Projects\",\"col\":12}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Project\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Project Billing Summary\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Projects\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Time Tracking\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]",
+ "content": "[{\"type\":\"chart\",\"data\":{\"chart_name\":\"Open Projects\",\"col\":12}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Project\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Project Billing Summary\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Projects\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Time Tracking\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]",
"creation": "2020-03-02 15:46:04.874669",
"docstatus": 0,
"doctype": "Workspace",
@@ -170,9 +170,27 @@
"link_type": "Report",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Settings",
+ "link_count": 1,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Projects Settings",
+ "link_count": 0,
+ "link_to": "Projects Settings",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2022-06-28 12:31:30.167740",
+ "modified": "2022-10-11 22:39:10.436311",
"modified_by": "Administrator",
"module": "Projects",
"name": "Projects",
diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js
index 5bb58faf2f..0cda93880f 100644
--- a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js
+++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js
@@ -5,7 +5,12 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
Object.assign(this, opts);
this.dialog_manager = new erpnext.accounts.bank_reconciliation.DialogManager(
this.company,
- this.bank_account
+ this.bank_account,
+ this.bank_statement_from_date,
+ this.bank_statement_to_date,
+ this.filter_by_reference_date,
+ this.from_reference_date,
+ this.to_reference_date
);
this.make_dt();
}
@@ -17,6 +22,8 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions",
args: {
bank_account: this.bank_account,
+ from_date: this.bank_statement_from_date,
+ to_date: this.bank_statement_to_date
},
callback: function (response) {
me.format_data(response.message);
@@ -30,28 +37,28 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
get_dt_columns() {
this.columns = [
{
- name: "Date",
+ name: __("Date"),
editable: false,
width: 100,
},
{
- name: "Party Type",
+ name: __("Party Type"),
editable: false,
width: 95,
},
{
- name: "Party",
+ name: __("Party"),
editable: false,
width: 100,
},
{
- name: "Description",
+ name: __("Description"),
editable: false,
width: 350,
},
{
- name: "Deposit",
+ name: __("Deposit"),
editable: false,
width: 100,
format: (value) =>
@@ -60,7 +67,7 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
"",
},
{
- name: "Withdrawal",
+ name: __("Withdrawal"),
editable: false,
width: 100,
format: (value) =>
@@ -69,26 +76,26 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
"",
},
{
- name: "Unallocated Amount",
+ name: __("Unallocated Amount"),
editable: false,
width: 100,
format: (value) =>
- "" +
+ "" +
format_currency(value, this.currency) +
"",
},
{
- name: "Reference Number",
+ name: __("Reference Number"),
editable: false,
width: 140,
},
{
- name: "Actions",
+ name: __("Actions"),
editable: false,
sortable: false,
focusable: false,
dropdown: false,
- width: 80,
+ width: 100,
},
];
}
@@ -118,7 +125,7 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager {
row["reference_number"],
`
|