diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 463d29c9f83..6b221433aa3 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -1,5 +1,6 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide('erpnext.integrations'); frappe.ui.form.on('Bank', { onload: function(frm) { @@ -7,6 +8,12 @@ frappe.ui.form.on('Bank', { }, refresh: function(frm) { add_fields_to_mapping_table(frm); + + if (frm.doc.plaid_access_token) { + frm.add_custom_button(__('Refresh Plaid Link'), () => { + new erpnext.integrations.refreshPlaidLink(frm.doc.plaid_access_token); + }); + } } }); @@ -27,4 +34,80 @@ let add_fields_to_mapping_table = function (frm) { frm.doc.name).options = options; frm.fields_dict.bank_transaction_mapping.grid.refresh(); -}; \ No newline at end of file +}; + +erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { + constructor(access_token) { + this.access_token = access_token; + this.plaidUrl = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; + this.init_config(); + } + + async init_config() { + this.plaid_env = await frappe.db.get_single_value('Plaid Settings', 'plaid_env'); + this.token = await this.get_link_token_for_update(); + this.init_plaid(); + } + + async get_link_token_for_update() { + const token = frappe.xcall( + 'erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.get_link_token_for_update', + { access_token: this.access_token } + ) + if (!token) { + frappe.throw(__('Cannot retrieve link token for update. Check Error Log for more information')); + } + return token; + } + + init_plaid() { + const me = this; + me.loadScript(me.plaidUrl) + .then(() => { + me.onScriptLoaded(me); + }) + .then(() => { + if (me.linkHandler) { + me.linkHandler.open(); + } + }) + .catch((error) => { + me.onScriptError(error); + }); + } + + loadScript(src) { + return new Promise(function (resolve, reject) { + if (document.querySelector("script[src='" + src + "']")) { + resolve(); + return; + } + const el = document.createElement('script'); + el.type = 'text/javascript'; + el.async = true; + el.src = src; + el.addEventListener('load', resolve); + el.addEventListener('error', reject); + el.addEventListener('abort', reject); + document.head.appendChild(el); + }); + } + + onScriptLoaded(me) { + me.linkHandler = Plaid.create({ + env: me.plaid_env, + token: me.token, + onSuccess: me.plaid_success + }); + } + + onScriptError(error) { + frappe.msgprint(__("There was an issue connecting to Plaid's authentication server. Check browser console for more information")); + console.log(error); + } + + plaid_success(token, response) { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + } +}; + diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 0fe57c32399..1aee0fd2b00 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -9,11 +9,13 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.page.bank_reconciliation.bank_reconciliation import reconcile, get_linked_payments +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile test_dependencies = ["Item", "Cost Center"] class TestBankTransaction(unittest.TestCase): def setUp(self): + make_pos_profile() add_transactions() add_payments() @@ -27,6 +29,9 @@ class TestBankTransaction(unittest.TestCase): frappe.db.sql("""delete from `tabPayment Entry Reference`""") frappe.db.sql("""delete from `tabPayment Entry`""") + # Delete POS Profile + frappe.db.sql("delete from `tabPOS Profile`") + frappe.flags.test_bank_transactions_created = False frappe.flags.test_payments_created = False diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry_list.js b/erpnext/accounts/doctype/payment_entry/payment_entry_list.js index 7ea60bb48ed..e6d83b9f683 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry_list.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry_list.js @@ -1,12 +1,14 @@ frappe.listview_settings['Payment Entry'] = { onload: function(listview) { - listview.page.fields_dict.party_type.get_query = function() { - return { - "filters": { - "name": ["in", Object.keys(frappe.boot.party_account_types)], - } + if (listview.page.fields_dict.party_type) { + listview.page.fields_dict.party_type.get_query = function() { + return { + "filters": { + "name": ["in", Object.keys(frappe.boot.party_account_types)], + } + }; }; - }; + } } }; \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 355fe96c967..6b07197ec10 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -37,6 +37,11 @@ frappe.ui.form.on("Payment Reconciliation Payment", { erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.extend({ onload: function() { var me = this; + + this.frm.set_query("party", function() { + check_mandatory(me.frm); + }); + this.frm.set_query("party_type", function() { return { "filters": { @@ -46,37 +51,39 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext }); this.frm.set_query('receivable_payable_account', function() { - if(!me.frm.doc.company || !me.frm.doc.party_type) { - frappe.msgprint(__("Please select Company and Party Type first")); - } else { - return{ - filters: { - "company": me.frm.doc.company, - "is_group": 0, - "account_type": frappe.boot.party_account_types[me.frm.doc.party_type] - } - }; - } - + check_mandatory(me.frm); + return { + filters: { + "company": me.frm.doc.company, + "is_group": 0, + "account_type": frappe.boot.party_account_types[me.frm.doc.party_type] + } + }; }); this.frm.set_query('bank_cash_account', function() { - if(!me.frm.doc.company) { - frappe.msgprint(__("Please select Company first")); - } else { - return{ - filters:[ - ['Account', 'company', '=', me.frm.doc.company], - ['Account', 'is_group', '=', 0], - ['Account', 'account_type', 'in', ['Bank', 'Cash']] - ] - }; - } + check_mandatory(me.frm, true); + return { + filters:[ + ['Account', 'company', '=', me.frm.doc.company], + ['Account', 'is_group', '=', 0], + ['Account', 'account_type', 'in', ['Bank', 'Cash']] + ] + }; }); this.frm.set_value('party_type', ''); this.frm.set_value('party', ''); this.frm.set_value('receivable_payable_account', ''); + + var check_mandatory = (frm, only_company=false) => { + var title = __("Mandatory"); + if (only_company && !frm.doc.company) { + frappe.throw({message: __("Please Select a Company First"), title: title}); + } else if (!frm.doc.company || !frm.doc.party_type) { + frappe.throw({message: __("Please Select Both Company and Party Type First"), title: title}); + } + }; }, refresh: function() { @@ -90,7 +97,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext party: function() { var me = this - if(!me.frm.doc.receivable_payable_account && me.frm.doc.party_type && me.frm.doc.party) { + if (!me.frm.doc.receivable_payable_account && me.frm.doc.party_type && me.frm.doc.party) { return frappe.call({ method: "erpnext.accounts.party.get_party_account", args: { @@ -99,7 +106,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext party: me.frm.doc.party }, callback: function(r) { - if(!r.exc && r.message) { + if (!r.exc && r.message) { me.frm.set_value("receivable_payable_account", r.message); } } diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index 29d83783d07..925fe7fcc36 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -404,6 +404,7 @@ "fieldtype": "Column Break" }, { + "default": "0", "depends_on": "eval:doc.rate_or_discount==\"Rate\"", "fieldname": "rate", "fieldtype": "Currency", @@ -467,6 +468,7 @@ "options": "UOM" }, { + "description": "If rate is zero them item will be treated as \"Free Item\"", "fieldname": "free_item_rate", "fieldtype": "Currency", "label": "Rate" @@ -554,7 +556,8 @@ ], "icon": "fa fa-gift", "idx": 1, - "modified": "2019-12-18 17:29:22.957077", + "links": [], + "modified": "2020-12-04 00:36:24.698219", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index da0824a97b2..1fb6dd38bb6 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -342,8 +342,14 @@ def apply_price_discount_rule(pricing_rule, item_details, args): pricing_rule_rate = 0.0 if pricing_rule.currency == args.currency: pricing_rule_rate = pricing_rule.rate + + if pricing_rule_rate: + # 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), + }) item_details.update({ - "price_list_rate": pricing_rule_rate * args.get("conversion_factor", 1), "discount_percentage": 0.0 }) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 2bf0b725635..8e819d634cb 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -385,7 +385,7 @@ class TestPricingRule(unittest.TestCase): so.load_from_db() self.assertEqual(so.items[1].is_free_item, 1) self.assertEqual(so.items[1].item_code, "_Test Item 2") - + def test_cumulative_pricing_rule(self): frappe.delete_doc_if_exists('Pricing Rule', '_Test Cumulative Pricing Rule') test_record = { @@ -430,6 +430,59 @@ class TestPricingRule(unittest.TestCase): self.assertTrue(details) + def test_item_price_with_pricing_rule(self): + item = make_item("Water Flask") + make_item_price("Water Flask", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Water Flask Rule", + "apply_on": "Item Code", + "items": [{ + "item_code": "Water Flask", + }], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Rate", + "rate": 0, + "margin_type": "Percentage", + "margin_rate_or_amount": 2, + "company": "_Test Company" + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice(do_not_save=True, item_code="Water Flask") + si.selling_price_list = "_Test Price List" + si.save() + + # If rate in Rule is 0, give preference to Item Price if it exists + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].margin_rate_or_amount, 2) + self.assertEqual(si.items[0].rate_with_margin, 102) + self.assertEqual(si.items[0].rate, 102) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() + item.delete() + + def test_pricing_rule_for_transaction(self): + make_item("Water Flask 1") + frappe.delete_doc_if_exists('Pricing Rule', '_Test Pricing Rule') + make_pricing_rule(selling=1, min_qty=5, price_or_product_discount="Product", + apply_on="Transaction", free_item="Water Flask 1", free_qty=1, free_item_rate=10) + + si = create_sales_invoice(qty=5, do_not_submit=True) + self.assertEquals(len(si.items), 2) + self.assertEquals(si.items[1].rate, 10) + + si1 = create_sales_invoice(qty=2, do_not_submit=True) + self.assertEquals(len(si1.items), 1) + + for doc in [si, si1]: + doc.delete() + def make_pricing_rule(**args): args = frappe._dict(args) @@ -447,15 +500,23 @@ def make_pricing_rule(**args): "rate_or_discount": args.rate_or_discount or "Discount Percentage", "discount_percentage": args.discount_percentage or 0.0, "rate": args.rate or 0.0, - "margin_type": args.margin_type, - "margin_rate_or_amount": args.margin_rate_or_amount or 0.0 + "margin_rate_or_amount": args.margin_rate_or_amount or 0.0, + "condition": args.condition or '', + "apply_multiple_pricing_rules": args.apply_multiple_pricing_rules or 0 }) + for field in ["free_item", "free_qty", "free_item_rate", "priority", + "margin_type", "price_or_product_discount"]: + if args.get(field): + doc.set(field, args.get(field)) + apply_on = doc.apply_on.replace(' ', '_').lower() child_table = {'Item Code': 'items', 'Item Group': 'item_groups', 'Brand': 'brands'} - doc.append(child_table.get(doc.apply_on), { - apply_on: args.get(apply_on) or "_Test Item" - }) + + if doc.apply_on != "Transaction": + doc.append(child_table.get(doc.apply_on), { + apply_on: args.get(apply_on) or "_Test Item" + }) doc.insert(ignore_permissions=True) if args.get(apply_on) and apply_on != "item_code": diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 27541670a89..fb2f9ff8991 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -453,6 +453,9 @@ def apply_pricing_rule_on_transaction(doc): pricing_rules = filter_pricing_rules_for_qty_amount(doc.total_qty, doc.total, pricing_rules) + if not pricing_rules: + remove_free_item(doc) + for d in pricing_rules: if d.price_or_product_discount == 'Price': if d.apply_discount_on: @@ -476,6 +479,12 @@ def apply_pricing_rule_on_transaction(doc): get_product_discount_rule(d, item_details, doc=doc) apply_pricing_rule_for_free_items(doc, item_details.free_item_data) doc.set_missing_values() + doc.calculate_taxes_and_totals() + +def remove_free_item(doc): + for d in doc.items: + if d.is_free_item: + doc.remove(d) def get_applied_pricing_rules(pricing_rules): if pricing_rules: @@ -488,7 +497,7 @@ def get_applied_pricing_rules(pricing_rules): def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): free_item = pricing_rule.free_item - if pricing_rule.same_item: + if pricing_rule.same_item and pricing_rule.get("apply_on") != 'Transaction': free_item = item_details.item_code or args.item_code if not free_item: @@ -517,13 +526,17 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): if item_details.get("parenttype") == 'Sales Order': item_details.free_item_data['delivery_date'] = doc.delivery_date if doc else today() - company = args.get('company') or doc.company - item_details.free_item_data['income_account'] = get_default_income_account( - args=args, - item=get_item_defaults(free_item, company), - item_group=get_item_group_defaults(free_item, company), - brand=get_brand_defaults(free_item, company), - ) + company = doc.company + if args and args.get("company"): + company = args.get("company") + + if args: + item_details.free_item_data['income_account'] = get_default_income_account( + args=args, + item=get_item_defaults(free_item, company), + item_group=get_item_group_defaults(free_item, company), + brand=get_brand_defaults(free_item, company), + ) def apply_pricing_rule_for_free_items(doc, pricing_rule_args, set_missing_values=False): if pricing_rule_args.get('item_code'): diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 7f5603b58b0..1243145e982 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -398,6 +398,8 @@ class SalesInvoice(SellingController): from erpnext.stock.get_item_details import get_pos_profile_item_details, get_pos_profile if not self.pos_profile: pos_profile = get_pos_profile(self.company) or {} + if not pos_profile: + frappe.throw(_("No POS Profile found. Please create a New POS Profile first")) self.pos_profile = pos_profile.get('name') pos = {} @@ -1395,6 +1397,7 @@ def make_delivery_note(source_name, target_doc=None): def set_missing_values(source, target): target.ignore_pricing_rule = 1 target.run_method("set_missing_values") + target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") def update_item(source_doc, target_doc, source_parent): diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 26f488d8c9a..4a6a771d099 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -690,7 +690,8 @@ class TestSalesInvoice(unittest.TestCase): self.assertFalse(gle) def test_pos_gl_entry_with_perpetual_inventory(self): - make_pos_profile() + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") pr = make_purchase_receipt(company= "_Test Company with perpetual inventory",supplier_warehouse= "Work In Progress - TCP1", item_code= "_Test FG Item",warehouse= "Stores - TCP1",cost_center= "Main - TCP1") @@ -773,7 +774,8 @@ class TestSalesInvoice(unittest.TestCase): def test_pos_change_amount(self): - make_pos_profile() + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") pr = make_purchase_receipt(company= "_Test Company with perpetual inventory",supplier_warehouse= "Work In Progress - TCP1", item_code= "_Test FG Item",warehouse= "Stores - TCP1",cost_center= "Main - TCP1") @@ -795,7 +797,8 @@ class TestSalesInvoice(unittest.TestCase): def test_make_pos_invoice(self): from erpnext.accounts.doctype.sales_invoice.pos import make_invoice - pos_profile = make_pos_profile() + pos_profile = make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") pr = make_purchase_receipt(company= "_Test Company with perpetual inventory",supplier_warehouse= "Work In Progress - TCP1", item_code= "_Test FG Item",warehouse= "Stores - TCP1",cost_center= "Main - TCP1") pos = create_sales_invoice(company= "_Test Company with perpetual inventory", debit_to="Debtors - TCP1", item_code= "_Test FG Item", warehouse="Stores - TCP1", income_account = "Sales - TCP1", expense_account = "Cost of Goods Sold - TCP1", cost_center = "Main - TCP1", do_not_save=True) diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json index c17f3aeb846..fb5bb4839c4 100644 --- a/erpnext/accounts/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -13,6 +13,7 @@ "cancelation_date", "trial_period_start", "trial_period_end", + "generate_new_invoices_past_due_date", "column_break_11", "current_invoice_start", "current_invoice_end", @@ -183,8 +184,7 @@ "fieldname": "invoices", "fieldtype": "Table", "label": "Invoices", - "options": "Subscription Invoice", - "read_only": 1 + "options": "Subscription Invoice" }, { "collapsible": 1, @@ -195,9 +195,16 @@ { "fieldname": "dimension_col_break", "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date", + "fieldname": "generate_new_invoices_past_due_date", + "fieldtype": "Check", + "label": "Generate New Invoices Past Due Date" } ], - "modified": "2020-08-27 23:30:02.504042", + "modified": "2020-11-29 22:46:14.879289", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription", diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 4ec2e65b2a3..ae0059cd3cd 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -408,6 +408,15 @@ class Subscription(Document): else: self.set_status_grace_period() + if getdate() > getdate(self.current_invoice_end): + self.update_subscription_period(add_days(self.current_invoice_end, 1)) + + # Generate invoices periodically even if current invoice are unpaid + if self.generate_new_invoices_past_due_date and not self.is_current_invoice_generated() and (self.is_postpaid_to_invoice() + or self.is_prepaid_to_invoice()): + prorate = frappe.db.get_single_value('Subscription Settings', 'prorate') + self.generate_invoice(prorate) + @staticmethod def is_not_outstanding(invoice): """ diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py index b4fffec7d40..fb409daacda 100644 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py +++ b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py @@ -260,7 +260,11 @@ def check_amount_vs_description(amount_matching, description_matching): continue if "reference_no" in am_match and "reference_no" in des_match: - if difflib.SequenceMatcher(lambda x: x == " ", am_match["reference_no"], des_match["reference_no"]).ratio() > 70: + # Sequence Matcher does not handle None as input + am_reference = am_match["reference_no"] or "" + des_reference = des_match["reference_no"] or "" + + if difflib.SequenceMatcher(lambda x: x == " ", am_reference, des_reference).ratio() > 70: if am_match not in result: result.append(am_match) if result: diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 12e7b8b8c37..cf47daeceac 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -60,7 +60,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= billing_address=party_address, shipping_address=shipping_address) if fetch_payment_terms_template: - party_details["payment_terms_template"] = get_pyt_term_template(party.name, party_type, company) + party_details["payment_terms_template"] = get_payment_terms_template(party.name, party_type, company) if not party_details.get("currency"): party_details["currency"] = currency @@ -204,7 +204,7 @@ def set_account_and_due_date(party, account, party_type, company, posting_date, return out @frappe.whitelist() -def get_party_account(party_type, party, company): +def get_party_account(party_type, party, company=None): """Returns the account for the given `party`. Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), @@ -318,7 +318,7 @@ def get_due_date(posting_date, party_type, party, company=None, bill_date=None): due_date = None if (bill_date or posting_date) and party: due_date = bill_date or posting_date - template_name = get_pyt_term_template(party, party_type, company) + template_name = get_payment_terms_template(party, party_type, company) if template_name: due_date = get_due_date_from_template(template_name, posting_date, bill_date).strftime("%Y-%m-%d") @@ -425,7 +425,7 @@ def set_taxes(party, party_type, posting_date, company, customer_group=None, sup @frappe.whitelist() -def get_pyt_term_template(party_name, party_type, company=None): +def get_payment_terms_template(party_name, party_type, company=None): if party_type not in ("Customer", "Supplier"): return template = None diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index f6632fa2632..08695e7f0d5 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -160,6 +160,8 @@ class ReceivablePayableReport(object): else: # advance / unlinked payment or other adjustment row.paid -= gle_balance + if gle.cost_center: + row.cost_center = gle.cost_center def update_sub_total_row(self, row, party): total_row = self.total_row_map.get(party) @@ -210,7 +212,6 @@ class ReceivablePayableReport(object): for key, row in self.voucher_balance.items(): row.outstanding = flt(row.invoiced - row.paid - row.credit_note, self.currency_precision) row.invoice_grand_total = row.invoiced - if abs(row.outstanding) > 1.0/10 ** self.currency_precision: # non-zero oustanding, we must consider this row @@ -577,7 +578,7 @@ class ReceivablePayableReport(object): self.gl_entries = frappe.db.sql(""" select - name, posting_date, account, party_type, party, voucher_type, voucher_no, + name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center, against_voucher_type, against_voucher, account_currency, remarks, {0} from `tabGL Entry` @@ -741,6 +742,7 @@ class ReceivablePayableReport(object): self.add_column(_("Customer Contact"), fieldname='customer_primary_contact', fieldtype='Link', options='Contact') + self.add_column(label=_('Cost Center'), fieldname='cost_center', fieldtype='Data') self.add_column(label=_('Voucher Type'), fieldname='voucher_type', fieldtype='Data') self.add_column(label=_('Voucher No'), fieldname='voucher_no', fieldtype='Dynamic Link', options='voucher_type', width=180) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 04ef68357f2..78d36f14d82 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -232,7 +232,7 @@ 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'); } }, @@ -323,6 +323,10 @@ frappe.ui.form.on('Asset', { calculate_depreciation: function(frm) { frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation); + + if (frm.doc.calculate_depreciation) { + frm.trigger('set_finance_book'); + } }, gross_purchase_amount: function(frm) { diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index 79fcb957d4d..d422876047e 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -55,6 +55,7 @@ "fieldtype": "Date", "in_list_view": 1, "label": "Depreciation Posting Date", + "mandatory_depends_on": "eval:parent.doctype == 'Asset'", "reqd": 1 }, { @@ -86,7 +87,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-16 12:11:30.631788", + "modified": "2020-10-30 15:22:29.119868", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py index 8a954b94d1e..557246e7efc 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py @@ -108,7 +108,7 @@ def update_maintenance_log(asset_maintenance, item_code, item_name, task): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_team_members(doctype, txt, searchfield, start, page_len, filters): - return frappe.db.get_values('Maintenance Team Member', { 'parent': filters.get("maintenance_team") }) + return frappe.db.get_values('Maintenance Team Member', { 'parent': filters.get("maintenance_team") }, "team_member") @frappe.whitelist() def get_maintenance_log(asset_name): diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 3ebb12541ab..5b7d76d0f08 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -42,7 +42,7 @@ class SellingController(StockController): self.validate_max_discount() self.validate_selling_price() self.set_qty_as_per_stock_uom() - self.set_po_nos() + self.set_po_nos(for_validate=True) self.set_gross_profit() set_default_income_account_for_item(self) self.set_customer_address() @@ -364,14 +364,36 @@ class SellingController(StockController): })) self.make_sl_entries(sl_entries) - def set_po_nos(self): - if self.doctype in ("Delivery Note", "Sales Invoice") and hasattr(self, "items"): - ref_fieldname = "against_sales_order" if self.doctype == "Delivery Note" else "sales_order" - sales_orders = list(set([d.get(ref_fieldname) for d in self.items if d.get(ref_fieldname)])) - if sales_orders: - po_nos = frappe.get_all('Sales Order', 'po_no', filters = {'name': ('in', sales_orders)}) - if po_nos and po_nos[0].get('po_no'): - self.po_no = ', '.join(list(set([d.po_no for d in po_nos if d.po_no]))) + def set_po_nos(self, for_validate=False): + if self.doctype == 'Sales Invoice' and hasattr(self, "items"): + if for_validate and self.po_no: + return + self.set_pos_for_sales_invoice() + if self.doctype == 'Delivery Note' and hasattr(self, "items"): + if for_validate and self.po_no: + return + self.set_pos_for_delivery_note() + + def set_pos_for_sales_invoice(self): + po_nos = [] + if self.po_no: + po_nos.append(self.po_no) + self.get_po_nos('Sales Order', 'sales_order', po_nos) + self.get_po_nos('Delivery Note', 'delivery_note', po_nos) + self.po_no = ', '.join(list(set(x.strip() for x in ','.join(po_nos).split(',')))) + + def set_pos_for_delivery_note(self): + po_nos = [] + if self.po_no: + po_nos.append(self.po_no) + self.get_po_nos('Sales Order', 'against_sales_order', po_nos) + self.get_po_nos('Sales Invoice', 'against_sales_invoice', po_nos) + self.po_no = ', '.join(list(set(x.strip() for x in ','.join(po_nos).split(',')))) + + def get_po_nos(self, ref_doctype, ref_fieldname, po_nos): + doc_list = list(set([d.get(ref_fieldname) for d in self.items if d.get(ref_fieldname)])) + if doc_list: + po_nos += [d.po_no for d in frappe.get_all(ref_doctype, 'po_no', filters = {'name': ('in', doc_list)}) if d.get('po_no')] def set_gross_profit(self): if self.doctype == "Sales Order": diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index da99f1267f6..1615e901350 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -246,22 +246,26 @@ class StatusUpdater(Document): if not args.get("second_source_extra_cond"): args["second_source_extra_cond"] = "" - args['second_source_condition'] = """ + ifnull((select sum(%(second_source_field)s) + args['second_source_condition'] = frappe.db.sql(""" select ifnull((select sum(%(second_source_field)s) from `tab%(second_source_dt)s` where `%(second_join_field)s`="%(detail_id)s" - and (`tab%(second_source_dt)s`.docstatus=1) %(second_source_extra_cond)s FOR UPDATE), 0) """ % args + and (`tab%(second_source_dt)s`.docstatus=1) + %(second_source_extra_cond)s), 0) """ % args)[0][0] if args['detail_id']: if not args.get("extra_cond"): args["extra_cond"] = "" - frappe.db.sql("""update `tab%(target_dt)s` - set %(target_field)s = ( + args["source_dt_value"] = frappe.db.sql(""" (select ifnull(sum(%(source_field)s), 0) from `tab%(source_dt)s` where `%(join_field)s`="%(detail_id)s" and (docstatus=1 %(cond)s) %(extra_cond)s) - %(second_source_condition)s - ) - %(update_modified)s + """ % args)[0][0] or 0.0 + + if args['second_source_condition']: + args["source_dt_value"] += flt(args['second_source_condition']) + + frappe.db.sql("""update `tab%(target_dt)s` + set %(target_field)s = %(source_dt_value)s %(update_modified)s where name='%(detail_id)s'""" % args) def _update_percent_field_in_targets(self, args, update_modified=True): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 8cad82c3e25..712fd3a51f5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -227,9 +227,9 @@ class StockController(AccountsController): def check_expense_account(self, item): if not item.get("expense_account"): - frappe.throw(_("Row #{0}: Expense Account not set for Item {1}. Please set an Expense \ - Account in the Items table").format(item.idx, frappe.bold(item.item_code)), - title=_("Expense Account Missing")) + msg = _("Please set an Expense Account in the Items table") + frappe.throw(_("Row #{0}: Expense Account not set for the Item {1}. {2}") + .format(item.idx, frappe.bold(item.item_code), msg), title=_("Expense Account Missing")) else: is_expense_account = frappe.db.get_value("Account", @@ -242,11 +242,12 @@ class StockController(AccountsController): _(self.doctype), self.name, item.get("item_code"))) def delete_auto_created_batches(self): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos for d in self.items: if not d.batch_no: continue - serial_nos = get_serial_nos(d.serial_no) + serial_nos = [sr.name for sr in frappe.get_all("Serial No", + {'batch_no': d.batch_no, 'status': 'Inactive'})] + if serial_nos: frappe.db.set_value("Serial No", { 'name': ['in', serial_nos] }, "batch_no", None) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 28bfb7a0072..8fdda67f53b 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -531,16 +531,6 @@ class calculate_taxes_and_totals(object): self._set_in_company_currency(self.doc, ['write_off_amount']) if self.doc.doctype in ["Sales Invoice", "Purchase Invoice"]: - grand_total = self.doc.rounded_total or self.doc.grand_total - if self.doc.party_account_currency == self.doc.currency: - total_amount_to_pay = flt(grand_total - self.doc.total_advance - - flt(self.doc.write_off_amount), self.doc.precision("grand_total")) - else: - total_amount_to_pay = flt(flt(grand_total * - self.doc.conversion_rate, self.doc.precision("grand_total")) - self.doc.total_advance - - flt(self.doc.base_write_off_amount), self.doc.precision("grand_total")) - - self.doc.round_floats_in(self.doc, ["paid_amount"]) change_amount = 0 if self.doc.doctype == "Sales Invoice" and not self.doc.get('is_return'): @@ -549,14 +539,10 @@ class calculate_taxes_and_totals(object): change_amount = self.doc.change_amount \ if self.doc.party_account_currency == self.doc.currency else self.doc.base_change_amount - paid_amount = self.doc.paid_amount \ - if self.doc.party_account_currency == self.doc.currency else self.doc.base_paid_amount - - self.doc.outstanding_amount = flt(total_amount_to_pay - flt(paid_amount) + flt(change_amount), - self.doc.precision("outstanding_amount")) + calculate_outstanding_amount(self.doc, change_amount) if self.doc.doctype == 'Sales Invoice' and self.doc.get('is_pos') and self.doc.get('is_return'): - self.update_paid_amount_for_return(total_amount_to_pay) + self.update_paid_amount_for_return(self.doc.total_amount_to_pay) def calculate_paid_amount(self): @@ -751,3 +737,20 @@ def get_rounded_tax_amount(itemised_tax, precision): for taxes in itemised_tax.values(): for tax_account in taxes: taxes[tax_account]["tax_amount"] = flt(taxes[tax_account]["tax_amount"], precision) + +def calculate_outstanding_amount(doc, change_amount=None): + grand_total = doc.rounded_total or doc.grand_total + if doc.party_account_currency == doc.currency: + doc.total_amount_to_pay = flt(grand_total - doc.total_advance + - flt(doc.write_off_amount), doc.precision("grand_total")) + else: + doc.total_amount_to_pay = flt(flt(grand_total * + doc.conversion_rate, doc.precision("grand_total")) - doc.total_advance + - flt(doc.base_write_off_amount), doc.precision("grand_total")) + + doc.round_floats_in(doc, ["paid_amount"]) + paid_amount = doc.paid_amount \ + if doc.party_account_currency == doc.currency else doc.base_paid_amount + + doc.outstanding_amount = flt(doc.total_amount_to_pay - flt(paid_amount) + flt(change_amount), + doc.precision("outstanding_amount")) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index a033a2a722d..b34432ae202 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -29,21 +29,32 @@ class PlaidConnector(): response = self.client.Item.public_token.exchange(public_token) access_token = response["access_token"] return access_token - - def get_link_token(self): - token_request = { + + def get_token_request(self, update_mode=False): + args = { "client_name": self.client_name, - "client_id": self.settings.plaid_client_id, - "secret": self.settings.plaid_secret, - "products": self.products, # only allow Plaid-supported languages and countries (LAST: Sep-19-2020) "language": frappe.local.lang if frappe.local.lang in ["en", "fr", "es", "nl"] else "en", - "country_codes": ["US", "CA", "FR", "IE", "NL", "ES", "GB"], + "country_codes": ["US", "CA", "ES", "FR", "GB", "IE", "NL"], "user": { "client_user_id": frappe.generate_hash(frappe.session.user, length=32) } } + if update_mode: + args["access_token"] = self.access_token + else: + args.update({ + "client_id": self.settings.plaid_client_id, + "secret": self.settings.plaid_secret, + "products": self.products, + }) + + return args + + def get_link_token(self, update_mode=False): + token_request = self.get_token_request(update_mode) + try: response = self.client.LinkToken.create(token_request) except InvalidRequestError: diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 22a4004955f..72705158251 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -12,7 +12,7 @@ frappe.ui.form.on('Plaid Settings', { refresh: function (frm) { if (frm.doc.enabled) { - frm.add_custom_button('Link a new bank account', () => { + frm.add_custom_button(__('Link a new bank account'), () => { new erpnext.integrations.plaidLink(frm); }); } @@ -30,10 +30,18 @@ erpnext.integrations.plaidLink = class plaidLink { this.product = ["auth", "transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; - this.token = await this.frm.call("get_link_token").then(resp => resp.message); + this.token = await this.get_link_token(); this.init_plaid(); } + async get_link_token() { + const token = await this.frm.call("get_link_token").then(resp => resp.message); + if (!token) { + frappe.throw(__('Cannot retrieve link token. Check Error Log for more information')); + } + return token; + } + init_plaid() { const me = this; me.loadScript(me.plaidUrl) @@ -78,8 +86,8 @@ erpnext.integrations.plaidLink = class plaidLink { } onScriptError(error) { - frappe.msgprint("There was an issue connecting to Plaid's authentication server"); - frappe.msgprint(error); + frappe.msgprint(__("There was an issue connecting to Plaid's authentication server. Check browser console for more information")); + console.log(error); } plaid_success(token, response) { @@ -107,4 +115,4 @@ erpnext.integrations.plaidLink = class plaidLink { }); }, __("Select a company"), __("Continue")); } -}; +}; \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 3afccf95b8e..ae8abf2eb15 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -239,3 +239,8 @@ def automatic_synchronization(): bank=plaid_account.bank, bank_account=plaid_account.name ) + +@frappe.whitelist() +def get_link_token_for_update(access_token): + plaid = PlaidConnector(access_token) + return plaid.get_link_token(update_mode=True) \ No newline at end of file diff --git a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py index 26b14504630..b2828cf857f 100644 --- a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py +++ b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py @@ -7,6 +7,7 @@ import frappe import unittest from frappe.utils.make_random import get_random from frappe.utils import nowdate, add_days, getdate +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile test_dependencies = ["Company"] @@ -14,6 +15,7 @@ class TestFeeValidity(unittest.TestCase): def test_fee_validity(self): frappe.db.sql("""delete from `tabPatient Appointment`""") frappe.db.sql("""delete from `tabFee Validity`""") + make_pos_profile() patient = get_random("Patient") practitioner = get_random("Healthcare Practitioner") department = get_random("Medical Department") diff --git a/erpnext/hr/doctype/department_approver/department_approver.py b/erpnext/hr/doctype/department_approver/department_approver.py index 70a0aa217f7..1200ae1de16 100644 --- a/erpnext/hr/doctype/department_approver/department_approver.py +++ b/erpnext/hr/doctype/department_approver/department_approver.py @@ -20,7 +20,7 @@ def get_approvers(doctype, txt, searchfield, start, page_len, filters): approvers = [] department_details = {} department_list = [] - employee = frappe.get_value("Employee", filters.get("employee"), ["department", "leave_approver"], as_dict=True) + employee = frappe.get_value("Employee", filters.get("employee"), ["employee_name","department", "leave_approver"], as_dict=True) employee_department = filters.get("department") or employee.department if employee_department: @@ -36,8 +36,10 @@ def get_approvers(doctype, txt, searchfield, start, page_len, filters): if filters.get("doctype") == "Leave Application": parentfield = "leave_approvers" - else: + field_name = "Leave Approver" + elif filters.get("doctype") == "Expense Claim": parentfield = "expense_approvers" + field_name = "Expense Approver" if department_list: for d in department_list: approvers += frappe.db.sql("""select user.name, user.first_name, user.last_name from @@ -47,4 +49,10 @@ def get_approvers(doctype, txt, searchfield, start, page_len, filters): and approver.parentfield = %s and approver.approver=user.name""",(d, "%" + txt + "%", parentfield), as_list=True) + if len(approvers) == 0: + error_msg = _("Please set {0} for the Employee: {1}").format(field_name, frappe.bold(employee.employee_name)) + if department_list: + error_msg += _(" or for Department: {0}").format(frappe.bold(employee_department)) + frappe.throw(error_msg, title=_(field_name + " Missing")) + return set(tuple(approver) for approver in approvers) diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index 2f88e1e3631..a746a9c3a8b 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -172,8 +172,11 @@ class Employee(NestedSet): ) if reports_to: link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to] - throw(_("Employee status cannot be set to 'Left' as following employees are currently reporting to this employee: ") - + ', '.join(link_to_employees), EmployeeLeftValidationError) + message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name)) + message += "


" + message += _("Please make sure the employees above report to another Active employee.") + throw(message, EmployeeLeftValidationError, _("Cannot Relieve Employee")) if not self.relieving_date: throw(_("Please enter relieving date.")) @@ -206,7 +209,7 @@ class Employee(NestedSet): def validate_preferred_email(self): if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)): - frappe.msgprint(_("Please enter " + self.prefered_contact_email)) + frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email)) def validate_onboarding_process(self): employee_onboarding = frappe.get_all("Employee Onboarding", @@ -407,7 +410,11 @@ def get_employee_emails(employee_list): @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): - filters = [['company', '=', company]] + + filters = [['status', '!=', 'Left']] + if company and company != 'All Companies': + filters.append(['company', '=', company]) + fields = ['name as value', 'employee_name as title'] if is_root: diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.py b/erpnext/hr/doctype/leave_encashment/leave_encashment.py index 7d6fd422c0f..76c227519b5 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.py +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.py @@ -95,7 +95,11 @@ class LeaveEncashment(Document): create_leave_ledger_entry(self, args, submit) # create reverse entry for expired leaves - to_date = self.get_leave_allocation().get('to_date') + leave_allocation = self.get_leave_allocation() + if not leave_allocation: + return + + to_date = leave_allocation.get('to_date') if to_date < getdate(nowdate()): args = frappe._dict( leaves=self.encashable_days, diff --git a/erpnext/hr/doctype/payroll_entry/payroll_entry.py b/erpnext/hr/doctype/payroll_entry/payroll_entry.py index f8aad316ed9..49ec828c979 100644 --- a/erpnext/hr/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/hr/doctype/payroll_entry/payroll_entry.py @@ -302,7 +302,9 @@ class PayrollEntry(Document): jv_name = journal_entry.name self.update_salary_slip_status(jv_name = jv_name) except Exception as e: - frappe.msgprint(e) + if type(e) in (str, list, tuple): + frappe.msgprint(e) + raise return jv_name @@ -379,9 +381,13 @@ class PayrollEntry(Document): employees_to_mark_attendance = [] days_in_payroll, days_holiday, days_attendance_marked = 0, 0, 0 for employee_detail in self.employees: - days_holiday = self.get_count_holidays_of_employee(employee_detail.employee) - days_attendance_marked = self.get_count_employee_attendance(employee_detail.employee) - days_in_payroll = date_diff(self.end_date, self.start_date) + 1 + employee_joining_date = frappe.db.get_value("Employee", employee_detail.employee, 'date_of_joining') + start_date = self.start_date + if employee_joining_date > getdate(self.start_date): + start_date = employee_joining_date + days_holiday = self.get_count_holidays_of_employee(employee_detail.employee, start_date) + days_attendance_marked = self.get_count_employee_attendance(employee_detail.employee, start_date) + days_in_payroll = date_diff(self.end_date, start_date) + 1 if days_in_payroll > days_holiday + days_attendance_marked: employees_to_mark_attendance.append({ "employee": employee_detail.employee, @@ -389,22 +395,25 @@ class PayrollEntry(Document): }) return employees_to_mark_attendance - def get_count_holidays_of_employee(self, employee): + def get_count_holidays_of_employee(self, employee, start_date): holiday_list = get_holiday_list_for_employee(employee) holidays = 0 if holiday_list: days = frappe.db.sql("""select count(*) from tabHoliday where parent=%s and holiday_date between %s and %s""", (holiday_list, - self.start_date, self.end_date)) + start_date, self.end_date)) if days and days[0][0]: holidays = days[0][0] return holidays - def get_count_employee_attendance(self, employee): + def get_count_employee_attendance(self, employee, start_date): marked_days = 0 - attendances = frappe.db.sql("""select count(*) from tabAttendance where - employee=%s and docstatus=1 and attendance_date between %s and %s""", - (employee, self.start_date, self.end_date)) + attendances = frappe.get_all("Attendance", + fields = ["count(*)"], + filters = { + "employee": employee, + "attendance_date": ('between', [start_date, self.end_date]) + }, as_list=1) if attendances and attendances[0][0]: marked_days = attendances[0][0] return marked_days diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index c7443d911ce..7b924f7025a 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc from frappe.model.document import Document class OperationMismatchError(frappe.ValidationError): pass +class JobCardCancelError(frappe.ValidationError): pass class JobCard(Document): def validate(self): @@ -110,39 +111,54 @@ class JobCard(Document): for_quantity, time_in_mins = 0, 0 from_time_list, to_time_list = [], [] - field = "operation_id" data = frappe.get_all('Job Card', fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], - filters = {"docstatus": 1, "work_order": self.work_order, field: self.get(field)}) + filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id}) if data and len(data) > 0: - for_quantity = data[0].completed_qty - time_in_mins = data[0].time_in_mins + for_quantity = flt(data[0].completed_qty) + time_in_mins = flt(data[0].time_in_mins) - if self.get(field): - time_data = frappe.db.sql(""" + wo = frappe.get_doc('Work Order', self.work_order) + if self.operation_id: + self.validate_produced_quantity(for_quantity, wo) + self.update_work_order_data(for_quantity, time_in_mins, wo) + + def validate_produced_quantity(self, for_quantity, wo): + if self.docstatus < 2: return + + if wo.produced_qty > for_quantity: + first_part_msg = (_("The {0} {1} is used to calculate the valuation cost for the finished good {2}.") + .format(frappe.bold(_("Job Card")), frappe.bold(self.name), frappe.bold(self.production_item))) + + second_part_msg = (_("Kindly cancel the Manufacturing Entries first against the work order {0}.") + .format(frappe.bold(get_link_to_form("Work Order", self.work_order)))) + + frappe.throw(_("{0} {1}").format(first_part_msg, second_part_msg), + JobCardCancelError, title = _("Error")) + + 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.{0} = %s and jc.docstatus = 1 - """.format(field), (self.work_order, self.get(field)), as_dict=1) + and jc.operation_id = %s and jc.docstatus = 1 + """, (self.work_order, self.operation_id), as_dict=1) - wo = frappe.get_doc('Work Order', self.work_order) + for data in wo.operations: + if data.get("name") == self.operation_id: + data.completed_qty = for_quantity + data.actual_operation_time = time_in_mins + data.actual_start_time = time_data[0].start_time if time_data else None + data.actual_end_time = time_data[0].end_time if time_data else None - for data in wo.operations: - if data.get("name") == self.get(field): - data.completed_qty = for_quantity - data.actual_operation_time = time_in_mins - data.actual_start_time = time_data[0].start_time if time_data else None - data.actual_end_time = time_data[0].end_time if time_data else None - - wo.flags.ignore_validate_update_after_submit = True - wo.update_operation_status() - wo.calculate_operating_cost() - wo.set_actual_dates() - wo.save() + wo.flags.ignore_validate_update_after_submit = True + wo.update_operation_status() + wo.calculate_operating_cost() + wo.set_actual_dates() + wo.save() def set_transferred_qty(self, update_status=False): if not self.items: @@ -224,17 +240,19 @@ def get_operation_details(work_order, operation): @frappe.whitelist() def get_operations(doctype, txt, searchfield, start, page_len, filters): - if filters.get("work_order"): - args = {"parent": filters.get("work_order")} - if txt: - args["operation"] = ("like", "%{0}%".format(txt)) + if not filters.get("work_order"): + frappe.msgprint(_("Please select a Work Order first.")) + return [] + args = {"parent": filters.get("work_order")} + if txt: + args["operation"] = ("like", "%{0}%".format(txt)) - return frappe.get_all("Work Order Operation", - filters = args, - fields = ["distinct operation as operation"], - limit_start = start, - limit_page_length = page_len, - order_by="idx asc", as_list=1) + return frappe.get_all("Work Order Operation", + filters = args, + fields = ["distinct operation as operation"], + limit_start = start, + limit_page_length = page_len, + order_by="idx asc", as_list=1) @frappe.whitelist() def make_material_request(source_name, target_doc=None): diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index d82a4dd9fe8..b243c574d8a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -5,16 +5,17 @@ from __future__ import unicode_literals import unittest import frappe -from frappe.utils import flt, time_diff_in_hours, now, add_days, cint +from frappe.utils import flt, now, cint, add_to_date from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory -from erpnext.manufacturing.doctype.work_order.work_order \ - import make_stock_entry, ItemHasVariantError, stop_unstop, StockOverProductionError, OverProductionError from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.utils import get_bin from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.manufacturing.doctype.work_order.work_order import (make_stock_entry, + ItemHasVariantError, stop_unstop, StockOverProductionError, OverProductionError) +from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError class TestWorkOrder(unittest.TestCase): def setUp(self): @@ -193,6 +194,42 @@ class TestWorkOrder(unittest.TestCase): self.assertEqual(cint(bin1_on_end_production.projected_qty), cint(bin1_on_end_production.projected_qty)) + def test_backflush_qty_for_overpduction_manufacture(self): + cancel_stock_entry = [] + allow_overproduction("overproduction_percentage_for_work_order", 30) + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100) + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0) + + cancel_stock_entry.extend([ste1.name, ste2.name]) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 50)) + s1.submit() + cancel_stock_entry.append(s1.name) + + self.assertEqual(s1.items[0].qty, 50) + self.assertEqual(s1.items[1].qty, 100) + cancel_stock_entry.reverse() + for ste in cancel_stock_entry: + doc = frappe.get_doc("Stock Entry", ste) + doc.cancel() + + allow_overproduction("overproduction_percentage_for_work_order", 0) + def test_reserved_qty_for_stopped_production(self): test_stock_entry.make_stock_entry(item_code="_Test Item", target= self.warehouse, qty=100, basic_rate=100) @@ -283,6 +320,29 @@ class TestWorkOrder(unittest.TestCase): allow_overproduction("overproduction_percentage_for_work_order", 0) + def test_finished_good_valuation_rate(self): + allow_overproduction("overproduction_percentage_for_work_order", 0) + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0) + test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0) + + ste_doc = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 2)) + ste_doc.submit() + + ste_doc = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2)) + ste_doc.save() + + self.assertEquals(ste_doc.total_incoming_value, ste_doc.total_outgoing_value) + + for row in ste_doc.items: + if row.t_warehouse and not row.s_warehouse: + row.valuation_rate = 120 + ste_doc.save() + + self.assertEquals(ste_doc.total_incoming_value, ste_doc.total_outgoing_value) + def test_over_production_for_sales_order(self): so = make_sales_order(item_code="_Test FG Item", qty=2) @@ -338,14 +398,41 @@ class TestWorkOrder(unittest.TestCase): data = frappe.get_cached_value('BOM', {'docstatus': 1, 'with_operations': 1, 'company': '_Test Company'}, ['name', 'item']) - if data: - bom, bom_item = data + bom, bom_item = data - bom_doc = frappe.get_doc('BOM', bom) - work_order = make_wo_order_test_record(item=bom_item, qty=1, bom_no=bom) + bom_doc = frappe.get_doc('BOM', bom) + work_order = make_wo_order_test_record(item=bom_item, qty=1, + bom_no=bom, source_warehouse="_Test Warehouse - _TC") - job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) - self.assertEqual(len(job_cards), len(bom_doc.operations)) + for row in work_order.required_items: + test_stock_entry.make_stock_entry(item_code=row.item_code, + target="_Test Warehouse - _TC", qty=row.required_qty, basic_rate=100) + + ste = frappe.get_doc(make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)) + ste.submit() + + job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) + self.assertEqual(len(job_cards), len(bom_doc.operations)) + + for i, job_card in enumerate(job_cards): + doc = frappe.get_doc("Job Card", job_card) + doc.append("time_logs", { + "from_time": now(), + "hours": i, + "to_time": add_to_date(now(), i), + "completed_qty": doc.for_quantity + }) + doc.submit() + + ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) + ste1.submit() + + for job_card in job_cards: + doc = frappe.get_doc("Job Card", job_card) + self.assertRaises(JobCardCancelError, doc.cancel) + + ste1.cancel() + ste.cancel() def test_work_order_with_non_transfer_item(self): items = {'Finished Good Transfer Item': 1, '_Test FG Item': 1, '_Test FG Item 1': 0} @@ -371,6 +458,11 @@ class TestWorkOrder(unittest.TestCase): ste1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1)) self.assertEqual(len(ste1.items), 3) + def test_cost_center_for_manufacture(self): + wo_order = make_wo_order_test_record() + ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) + self.assertEquals(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") + def test_operation_time_with_batch_size(self): fg_item = "Test Batch Size Item For BOM" rm1 = "Test Batch Size Item RM 1 For BOM" 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 2ac6fa073bf..8cd016461cc 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js @@ -25,11 +25,11 @@ 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){ - value = `${data['Item']}` + if (column.id == "item") { + if (data["enough_parts_to_build"] > 0) { + value = `${data['item']}`; } else { - value = `${data['Item']}` + value = `${data['item']}`; } } return value diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1e704c86520..b5f31bafa7e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -635,7 +635,7 @@ execute:frappe.reload_doc('desk', 'doctype', 'dashboard_chart') execute:frappe.reload_doc('desk', 'doctype', 'dashboard_chart_field') erpnext.patches.v12_0.add_default_dashboards erpnext.patches.v12_0.remove_bank_remittance_custom_fields -erpnext.patches.v12_0.generate_leave_ledger_entries #27-08-2020 +erpnext.patches.v12_0.generate_leave_ledger_entries #04-11-2020 erpnext.patches.v12_0.move_credit_limit_to_customer_credit_limit erpnext.patches.v12_0.add_variant_of_in_item_attribute_table erpnext.patches.v12_0.rename_bank_account_field_in_journal_entry_account diff --git a/erpnext/patches/v12_0/generate_leave_ledger_entries.py b/erpnext/patches/v12_0/generate_leave_ledger_entries.py index 342c12996d1..fe072d7eb96 100644 --- a/erpnext/patches/v12_0/generate_leave_ledger_entries.py +++ b/erpnext/patches/v12_0/generate_leave_ledger_entries.py @@ -11,8 +11,6 @@ def execute(): frappe.reload_doc("HR", "doctype", "Leave Ledger Entry") frappe.reload_doc("HR", "doctype", "Leave Encashment") frappe.reload_doc("HR", "doctype", "Leave Type") - if frappe.db.a_row_exists("Leave Ledger Entry"): - return if not frappe.get_meta("Leave Allocation").has_field("unused_leaves"): frappe.reload_doc("HR", "doctype", "Leave Allocation") @@ -53,7 +51,7 @@ def generate_encashment_leave_ledger_entries(): for encashment in leave_encashments: if not frappe.db.exists("Leave Ledger Entry", {'transaction_type': 'Leave Encashment', 'transaction_name': encashment.name}): - frappe.get_doc("Leave Enchashment", encashment).create_leave_ledger_entry() + frappe.get_doc("Leave Encashment", encashment).create_leave_ledger_entry() def generate_expiry_allocation_ledger_entries(): ''' fix ledger entries for missing leave allocation transaction ''' diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 264344ca94c..6e2409cf875 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -665,9 +665,13 @@ erpnext.utils.map_current_doc = function(opts) { } frappe.form.link_formatters['Item'] = function(value, doc) { - if(doc && doc.item_name && doc.item_name !== value) { - return value? value + ': ' + doc.item_name: doc.item_name; + if (doc && value && doc.item_name && doc.item_name !== value) { + return value + ': ' + doc.item_name; + } else if (!value && doc.doctype && doc.item_name) { + // format blank value in child table + return doc.item_name; } else { + // if value is blank in report view or item code and name are the same, return as is return value; } } diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index 25c30acba76..9e841483554 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -192,19 +192,20 @@ class GSTR3BReport(Document): for d in self.report_dict["itc_elg"]["itc_avl"]: itc_type = itc_type_map.get(d["ty"]) - gst_category = ["Registered Regular"] if d["ty"] == 'ISRC': - reverse_charge = "Y" + reverse_charge = ["Y"] itc_type = 'All Other ITC' gst_category = ['Unregistered', 'Overseas', 'Registered Regular'] else: - reverse_charge = "N" + gst_category = ['Unregistered', 'Overseas', 'Registered Regular'] + reverse_charge = ["N", "Y"] for account_head in self.account_heads: for category in gst_category: - for key in [['iamt', 'igst_account'], ['camt', 'cgst_account'], ['samt', 'sgst_account'], ['csamt', 'cess_account']]: - d[key[0]] += flt(itc_details.get((category, itc_type, reverse_charge, account_head.get(key[1])), {}).get("amount"), 2) + for charge_type in reverse_charge: + for key in [['iamt', 'igst_account'], ['camt', 'cgst_account'], ['samt', 'sgst_account'], ['csamt', 'cess_account']]: + d[key[0]] += flt(itc_details.get((category, itc_type, charge_type, account_head.get(key[1])), {}).get("amount"), 2) for key in ['iamt', 'camt', 'samt', 'csamt']: net_itc[key] += flt(d[key], 2) @@ -264,7 +265,8 @@ class GSTR3BReport(Document): def get_itc_details(self): itc_amount = frappe.db.sql(""" - select s.gst_category, sum(t.tax_amount_after_discount_amount) as tax_amount, t.account_head, s.eligibility_for_itc, s.reverse_charge + select s.gst_category, sum(t.base_tax_amount_after_discount_amount) as tax_amount, + t.account_head, s.eligibility_for_itc, s.reverse_charge from `tabPurchase Invoice` s , `tabPurchase Taxes and Charges` t where s.docstatus = 1 and t.parent = s.name and month(s.posting_date) = %s and year(s.posting_date) = %s and s.company = %s @@ -388,7 +390,7 @@ class GSTR3BReport(Document): tax_template = 'Purchase Taxes and Charges' tax_amounts = frappe.db.sql(""" - select s.gst_category, sum(t.tax_amount_after_discount_amount) as tax_amount, t.account_head + select s.gst_category, sum(t.base_tax_amount_after_discount_amount) as tax_amount, t.account_head from `tab{doctype}` s , `tab{template}` t where s.docstatus = 1 and t.parent = s.name and s.reverse_charge = %s and month(s.posting_date) = %s and year(s.posting_date) = %s and s.company = %s diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index cb2f4c3429f..5872c69fd16 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -4,7 +4,7 @@ from frappe import _ import erpnext from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words from erpnext.regional.india import states, state_numbers -from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount +from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount, calculate_outstanding_amount from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.hr.utils import get_salary_assignment from erpnext.hr.doctype.salary_structure.salary_structure import make_salary_slip @@ -218,10 +218,9 @@ def get_tax_template(master_doctype, company, is_inter_state, state_code): for tax_category in tax_categories: if tax_category.gst_state == number_state_mapping[state_code] or \ - (not default_tax and not tax_category.gst_state): + (not default_tax and not tax_category.gst_state): default_tax = frappe.db.get_value(master_doctype, - {'disabled': 0, 'tax_category': tax_category.name}, 'name') - + {'company': company, 'disabled': 0, 'tax_category': tax_category.name}, 'name') return default_tax def get_tax_template_for_sez(party_details, master_doctype, company, party_type): @@ -501,6 +500,9 @@ def get_address_details(data, doc, company_address, billing_address): data.transType = 1 data.actualToStateCode = data.toStateCode shipping_address = billing_address + + if doc.gst_category == 'SEZ': + data.toStateCode = 99 return data @@ -690,16 +692,14 @@ def update_totals(gst_tax, base_gst_tax, doc): doc.grand_total -= gst_tax if doc.meta.get_field("rounded_total"): - if doc.is_rounded_total_disabled(): - doc.outstanding_amount = doc.grand_total - else: + if not doc.is_rounded_total_disabled(): doc.rounded_total = round_based_on_smallest_currency_fraction(doc.grand_total, doc.currency, doc.precision("rounded_total")) doc.rounding_adjustment += flt(doc.rounded_total - doc.grand_total, doc.precision("rounding_adjustment")) - doc.outstanding_amount = doc.rounded_total or doc.grand_total + calculate_outstanding_amount(doc) doc.in_words = money_in_words(doc.grand_total, doc.currency) doc.base_in_words = money_in_words(doc.base_grand_total, erpnext.get_company_currency(doc.company)) diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 9368d8fa9ac..28b77c5b694 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -151,6 +151,7 @@ class Gstr1Report(object): {select_columns} from `tab{doctype}` where docstatus = 1 {where_conditions} + and is_opening = 'No' order by posting_date desc """.format(select_columns=self.select_columns, doctype=self.doctype, where_conditions=conditions), self.filters, as_dict=1) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 273bf784fad..6d196fe0d72 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -15,6 +15,7 @@ class ProductBundle(Document): def validate(self): self.validate_main_item() self.validate_child_items() + self.validate_duplicate_packing_item() from erpnext.utilities.transaction_base import validate_uom_is_integer validate_uom_is_integer(self, "uom", "qty") @@ -28,6 +29,14 @@ class ProductBundle(Document): if frappe.db.exists("Product Bundle", item.item_code): frappe.throw(_("Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save").format(item.idx, frappe.bold(item.item_code))) + def validate_duplicate_packing_item(self): + items = [] + for d in self.items: + if d.item_code not in items: + items.append(d.item_code) + else: + frappe.throw(_("The item {0} added multiple times") + .format(frappe.bold(d.item_code)), title=_("Duplicate Item Error")) @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 423922e4865..6a9e43e273a 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -148,7 +148,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( // sales invoice if(flt(doc.per_billed, 6) < 100) { - this.frm.add_custom_button(__('Invoice'), () => me.make_sales_invoice(), __('Create')); + this.frm.add_custom_button(__('Sales Invoice'), () => me.make_sales_invoice(), __('Create')); } // material request @@ -542,19 +542,26 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, make_purchase_order: function(){ + let pending_items = this.frm.doc.items.some((item) =>{ + let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty); + return pending_qty > 0; + }) + if(!pending_items){ + frappe.throw({message: __("Purchase Order already created for all Sales Order items"), title: __("Note")}); + } + var me = this; var dialog = new frappe.ui.Dialog({ - title: __("For Supplier"), + title: __("Select Items"), fields: [ - {"fieldtype": "Link", "label": __("Supplier"), "fieldname": "supplier", "options":"Supplier", - "description": __("Leave the field empty to make purchase orders for all suppliers"), - "get_query": function () { - return { - query:"erpnext.selling.doctype.sales_order.sales_order.get_supplier", - filters: {'parent': me.frm.doc.name} - } - }}, - {fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', + { + "fieldtype": "Check", + "label": __("Against Default Supplier"), + "fieldname": "against_default_supplier", + "default": 0 + }, + { + fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', fields: [ { fieldtype:'Data', @@ -572,8 +579,8 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, { fieldtype:'Float', - fieldname:'qty', - label: __('Quantity'), + fieldname:'pending_qty', + label: __('Pending Qty'), read_only: 1, in_list_view:1 }, @@ -582,60 +589,97 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( read_only:1, fieldname:'uom', label: __('UOM'), + in_list_view:1, + }, + { + fieldtype:'Data', + fieldname:'supplier', + label: __('Supplier'), + read_only:1, in_list_view:1 - } - ], - data: cur_frm.doc.items, - get_data: function() { - return cur_frm.doc.items - } - }, - - {"fieldtype": "Button", "label": __('Create Purchase Order'), "fieldname": "make_purchase_order", "cssClass": "btn-primary"}, - ] - }); - - dialog.fields_dict.make_purchase_order.$input.click(function() { - var args = dialog.get_values(); - let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children() - if(selected_items.length == 0) { - frappe.throw({message: 'Please select Item form Table', title: __('Message'), indicator:'blue'}) - } - let selected_items_list = [] - for(let i in selected_items){ - selected_items_list.push(selected_items[i].item_code) - } - dialog.hide(); - return frappe.call({ - type: "GET", - method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order", - args: { - "source_name": me.frm.doc.name, - "for_supplier": args.supplier, - "selected_items": selected_items_list - }, - freeze: true, - callback: function(r) { - if(!r.exc) { - // var args = dialog.get_values(); - if (args.supplier){ - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); - } - else{ - frappe.route_options = { - "sales_order": me.frm.doc.name - } - frappe.set_route("List", "Purchase Order"); - } - } + }, + ] } - }) + ], + primary_action_label: 'Create Purchase Order', + primary_action (args) { + if (!args) return; + + let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children(); + if(selected_items.length == 0) { + frappe.throw({message: 'Please select Items from the Table', title: __('Items Required'), indicator:'blue'}) + } + + dialog.hide(); + + var method = args.against_default_supplier ? "make_purchase_order_for_default_supplier" : "make_purchase_order" + return frappe.call({ + method: "erpnext.selling.doctype.sales_order.sales_order." + method, + freeze: true, + freeze_message: __("Creating Purchase Order ..."), + args: { + "source_name": me.frm.doc.name, + "selected_items": selected_items + }, + freeze: true, + callback: function(r) { + if(!r.exc) { + if (!args.against_default_supplier) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + else { + frappe.route_options = { + "sales_order": me.frm.doc.name + } + frappe.set_route("List", "Purchase Order"); + } + } + } + }) + } }); - dialog.get_field("items_for_po").grid.only_sortable() - dialog.get_field("items_for_po").refresh() + + dialog.fields_dict["against_default_supplier"].df.onchange = () => set_po_items_data(dialog); + + function set_po_items_data (dialog) { + var against_default_supplier = dialog.get_value("against_default_supplier"); + var items_for_po = dialog.get_value("items_for_po"); + + if (against_default_supplier) { + let items_with_supplier = items_for_po.filter((item) => item.supplier) + + dialog.fields_dict["items_for_po"].df.data = items_with_supplier; + dialog.get_field("items_for_po").refresh(); + } else { + let po_items = []; + me.frm.doc.items.forEach(d => { + let pending_qty = (flt(d.stock_qty) - flt(d.ordered_qty)) / flt(d.conversion_factor); + if (pending_qty > 0) { + po_items.push({ + "doctype": "Sales Order Item", + "name": d.name, + "item_name": d.item_name, + "item_code": d.item_code, + "pending_qty": pending_qty, + "uom": d.uom, + "supplier": d.supplier + }); + } + }); + + dialog.fields_dict["items_for_po"].df.data = po_items; + dialog.get_field("items_for_po").refresh(); + } + } + + set_po_items_data(dialog); + dialog.get_field("items_for_po").grid.only_sortable(); + dialog.get_field("items_for_po").refresh(); + dialog.wrapper.find('.grid-heading-row .grid-row-check').click(); dialog.show(); }, + hold_sales_order: function(){ var me = this; var d = new frappe.ui.Dialog({ diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index ed3a96446a0..59df180ff8c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -443,25 +443,19 @@ class SalesOrder(SellingController): for item in self.items: if item.ensure_delivery_based_on_produced_serial_no: if item.item_code in normal_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) if item.item_code not in reserved_items: if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): - frappe.throw(_("Item {0} has no Serial No. Only serilialized items \ - can have delivery based on Serial No").format(item.item_code)) + frappe.throw(_("Item {0} has no Serial No. Only serilialized items can have delivery based on Serial No").format(item.item_code)) if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}): - frappe.throw(_("No active BOM found for item {0}. Delivery by \ - Serial No cannot be ensured").format(item.item_code)) + frappe.throw(_("No active BOM found for item {0}. Delivery by Serial No cannot be ensured").format(item.item_code)) reserved_items.append(item.item_code) else: normal_items.append(item.item_code) if not item.ensure_delivery_based_on_produced_serial_no and \ item.item_code in reserved_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -785,7 +779,9 @@ def get_events(start, end, filters=None): return data @frappe.whitelist() -def make_purchase_order(source_name, for_supplier=None, selected_items=[], target_doc=None): +def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None): + if not selected_items: return + if isinstance(selected_items, string_types): selected_items = json.loads(selected_items) @@ -822,24 +818,21 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe def update_item(source, target, source_parent): target.schedule_date = source.delivery_date - target.qty = flt(source.qty) - flt(source.ordered_qty) - target.stock_qty = (flt(source.qty) - flt(source.ordered_qty)) * flt(source.conversion_factor) + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) target.project = source_parent.project - suppliers =[] - if for_supplier: - suppliers.append(for_supplier) - else: - sales_order = frappe.get_doc("Sales Order", source_name) - for item in sales_order.items: - if item.supplier and item.supplier not in suppliers: - suppliers.append(item.supplier) + suppliers = [item.get('supplier') for item in selected_items if item.get('supplier') and item.get('supplier')] + suppliers = list(set(suppliers)) + + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) if not suppliers: frappe.throw(_("Please set a Supplier against the Items to be considered in the Purchase Order.")) for supplier in suppliers: - po =frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) + po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) if len(po) == 0: doc = get_mapped_doc("Sales Order", source_name, { "Sales Order": { @@ -850,7 +843,9 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe "contact_mobile", "contact_email", "contact_person", - "taxes_and_charges" + "taxes_and_charges", + "shipping_address", + "terms" ], "validation": { "docstatus": ["=", 1] @@ -869,55 +864,94 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe "field_no_map": [ "rate", "price_list_rate", - "item_tax_template" + "item_tax_template", + "discount_percentage", + "discount_amount", + "pricing_rules" ], "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty and doc.supplier == supplier and doc.item_code in selected_items + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier and doc.item_code in items_to_map } }, target_doc, set_missing_values) - if not for_supplier: - doc.insert() + + doc.insert() else: suppliers =[] if suppliers: - if not for_supplier: - frappe.db.commit() + frappe.db.commit() return doc else: - frappe.msgprint(_("PO already created for all sales order items")) - + frappe.msgprint(_("Purchase Order already created for all Sales Order items")) @frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def get_supplier(doctype, txt, searchfield, start, page_len, filters): - 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 = ", ".join(fields) +def make_purchase_order(source_name, selected_items=None, target_doc=None): + if not selected_items: return - return frappe.db.sql("""select {field} from `tabSupplier` - where docstatus < 2 - and ({key} like %(txt)s - or supplier_name like %(txt)s) - and name in (select supplier from `tabSales Order Item` where parent = %(parent)s) - and name not in (select supplier from `tabPurchase Order` po inner join `tabPurchase Order Item` poi - on po.name=poi.parent where po.docstatus<2 and poi.sales_order=%(parent)s) - order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), - name, supplier_name - limit %(start)s, %(page_len)s """.format(**{ - 'field': fields, - 'key': frappe.db.escape(searchfield) - }), { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len, - 'parent': filters.get('parent') - }) + if isinstance(selected_items, string_types): + selected_items = json.loads(selected_items) + + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) + + def set_missing_values(source, target): + target.supplier = "" + target.apply_discount_on = "" + target.additional_discount_percentage = 0.0 + target.discount_amount = 0.0 + target.inter_company_order_reference = "" + target.customer = "" + target.customer_name = "" + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(source, target, source_parent): + target.schedule_date = source.delivery_date + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) + target.project = source_parent.project + + # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) + doc = get_mapped_doc("Sales Order", source_name, { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [ + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "taxes_and_charges", + "shipping_address", + "terms" + ], + "validation": { + "docstatus": ["=", 1] + } + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"] + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "supplier", + "pricing_rules" + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map + } + }, target_doc, set_missing_values) + return doc @frappe.whitelist() def make_work_orders(items, sales_order, company, project=None): diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index fcde0d502e9..37c29befcd7 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -674,12 +674,12 @@ class TestSalesOrder(unittest.TestCase): frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1) def test_drop_shipping(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \ + update_status as so_update_status from erpnext.buying.doctype.purchase_order.purchase_order import update_status - make_stock_entry(target="_Test Warehouse - _TC", qty=10, rate=100) + # make items po_item = make_item("_Test Item for Drop Shipping", {"is_stock_item": 1, "delivered_by_supplier": 1}) - dn_item = make_item("_Test Regular Item", {"is_stock_item": 1}) so_items = [ @@ -701,80 +701,61 @@ class TestSalesOrder(unittest.TestCase): ] if frappe.db.get_value("Item", "_Test Regular Item", "is_stock_item")==1: - make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=10, rate=100) + make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=2, rate=100) - #setuo existing qty from bin - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - existing_ordered_qty = bin[0].ordered_qty if bin else 0.0 - existing_reserved_qty = bin[0].reserved_qty if bin else 0.0 - - bin = frappe.get_all("Bin", filters={"item_code": dn_item.item_code, - "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"]) - - existing_reserved_qty_for_dn_item = bin[0].reserved_qty if bin else 0.0 - - #create so, po and partial dn + #create so, po and dn so = make_sales_order(item_list=so_items, do_not_submit=True) so.submit() - po = make_purchase_order(so.name, '_Test Supplier', selected_items=[so_items[0]['item_code']]) + po = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]]) po.submit() - dn = create_dn_against_so(so.name, delivered_qty=1) + dn = create_dn_against_so(so.name, delivered_qty=2) self.assertEqual(so.customer, po.customer) self.assertEqual(po.items[0].sales_order, so.name) self.assertEqual(po.items[0].item_code, po_item.item_code) self.assertEqual(dn.items[0].item_code, dn_item.item_code) - - #test ordered_qty and reserved_qty - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 - - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) - - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item + 1) - #test po_item length self.assertEqual(len(po.items), 1) - #test per_delivered status + # test ordered_qty and reserved_qty for drop ship item + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + fields=["ordered_qty", "reserved_qty"]) + + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 + + # drop ship PO should not impact bin, test the same + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) + + # test per_delivered status update_status("Delivered", po.name) - self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 75.00) + self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00) + po.load_from_db() - #test reserved qty after complete delivery - dn = create_dn_against_so(so.name, delivered_qty=1) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) - - #test after closing so + # test after closing so so.db_set('status', "Closed") so.update_reserved_qty() - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + # test ordered_qty and reserved_qty for drop ship item after closing so + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, fields=["ordered_qty", "reserved_qty"]) - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) + # teardown + so_update_status("Draft", so.name) + dn.load_from_db() + dn.cancel() + po.cancel() + so.load_from_db() + so.cancel() def test_reserved_qty_for_closing_so(self): bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}, @@ -1050,6 +1031,7 @@ def make_sales_order(**args): so.company = args.company or "_Test Company" so.customer = args.customer or "_Test Customer" so.currency = args.currency or "INR" + so.po_no = args.po_no or '12345' if args.selling_price_list: so.selling_price_list = args.selling_price_list diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index f1728b7afda..a4a39ca4d03 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -1515,6 +1515,9 @@ class POSItems { } get_items({start = 0, page_length = 40, search_value='', item_group=this.parent_item_group}={}) { + if (!this.frm.doc.pos_profile) + return; + const price_list = this.frm.doc.selling_price_list; return new Promise(res => { frappe.call({ diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 14cacf37bb6..6305a7a925d 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -71,29 +71,41 @@ frappe.ui.form.on("Company", { frm.toggle_enable("default_currency", (frm.doc.__onload && !frm.doc.__onload.transactions_exist)); - frm.add_custom_button(__('Create Tax Template'), function() { - frm.trigger("make_default_tax_template"); - }); + if (frm.has_perm('write')) { + frm.add_custom_button(__('Create Tax Template'), function() { + frm.trigger("make_default_tax_template"); + }); + } + if (frappe.perm.has_perm("Cost Center", 0, 'read')) { + frm.add_custom_button(__('Cost Centers'), function() { + frappe.set_route('Tree', 'Cost Center', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Cost Centers'), function() { - frappe.set_route('Tree', 'Cost Center', {'company': frm.doc.name}) - }, __("View")); + if (frappe.perm.has_perm("Account", 0, 'read')) { + frm.add_custom_button(__('Chart of Accounts'), function() { + frappe.set_route('Tree', 'Account', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Chart of Accounts'), function() { - frappe.set_route('Tree', 'Account', {'company': frm.doc.name}) - }, __("View")); - frm.add_custom_button(__('Sales Tax Template'), function() { - frappe.set_route('List', 'Sales Taxes and Charges Template', {'company': frm.doc.name}); - }, __("View")); + if (frappe.perm.has_perm("Sales Taxes and Charges Template", 0, 'read')) { + frm.add_custom_button(__('Sales Tax Template'), function() { + frappe.set_route('List', 'Sales Taxes and Charges Template', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Purchase Tax Template'), function() { - frappe.set_route('List', 'Purchase Taxes and Charges Template', {'company': frm.doc.name}); - }, __("View")); + if (frappe.perm.has_perm("Purchase Taxes and Charges Template", 0, 'read')) { + frm.add_custom_button(__('Purchase Tax Template'), function() { + frappe.set_route('List', 'Purchase Taxes and Charges Template', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Default Tax Template'), function() { - frm.trigger("make_default_tax_template"); - }, __('Create')); + if (frm.has_perm('write')) { + frm.add_custom_button(__('Default Tax Template'), function() { + frm.trigger("make_default_tax_template"); + }, __('Create')); + } } erpnext.company.set_chart_of_accounts_options(frm.doc); diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 9d92d43ec2f..a995bfba524 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -57,7 +57,7 @@ class TestDeliveryNote(unittest.TestCase): sle = frappe.get_doc("Stock Ledger Entry", {"voucher_type": "Delivery Note", "voucher_no": dn.name}) - self.assertEqual(sle.stock_value_difference, -1*stock_queue[0][1]) + self.assertEqual(sle.stock_value_difference, flt(-1*stock_queue[0][1])) self.assertFalse(get_gl_entries("Delivery Note", dn.name)) @@ -442,9 +442,15 @@ class TestDeliveryNote(unittest.TestCase): self.assertEqual(dn.status, "To Bill") self.assertEqual(dn.per_billed, 0) + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn.po_no, so.po_no) + si = make_sales_invoice(dn.name) si.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn.po_no, si.po_no) + dn.load_from_db() self.assertEqual(dn.get("items")[0].billed_amt, 200) self.assertEqual(dn.per_billed, 100) @@ -461,6 +467,9 @@ class TestDeliveryNote(unittest.TestCase): si.insert() si.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(so.po_no, si.po_no) + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) dn1 = make_delivery_note(so.name) @@ -469,6 +478,9 @@ class TestDeliveryNote(unittest.TestCase): dn1.get("items")[0].qty = 2 dn1.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(so.po_no, dn1.po_no) + self.assertEqual(dn1.get("items")[0].billed_amt, 200) self.assertEqual(dn1.per_billed, 100) self.assertEqual(dn1.status, "Completed") @@ -479,6 +491,9 @@ class TestDeliveryNote(unittest.TestCase): dn2.get("items")[0].qty = 4 dn2.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(so.po_no, dn2.po_no) + dn1.load_from_db() self.assertEqual(dn1.get("items")[0].billed_amt, 100) self.assertEqual(dn1.per_billed, 50) @@ -502,9 +517,15 @@ class TestDeliveryNote(unittest.TestCase): dn1.get("items")[0].qty = 2 dn1.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn1.po_no, so.po_no) + si1 = make_sales_invoice(dn1.name) si1.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn1.po_no, si1.po_no) + dn1.load_from_db() self.assertEqual(dn1.per_billed, 100) @@ -512,11 +533,17 @@ class TestDeliveryNote(unittest.TestCase): si2.get("items")[0].qty = 4 si2.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(si2.po_no, so.po_no) + dn2 = make_delivery_note(so.name) dn2.posting_time = "08:00" dn2.get("items")[0].qty = 5 dn2.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn2.po_no, so.po_no) + dn1.load_from_db() self.assertEqual(dn1.get("items")[0].billed_amt, 200) self.assertEqual(dn1.per_billed, 100) @@ -536,9 +563,15 @@ class TestDeliveryNote(unittest.TestCase): si = make_sales_invoice(so.name) si.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(so.po_no, si.po_no) + dn = make_delivery_note(si.name) dn.submit() + # Testing if Customer's Purchase Order No was rightly copied + self.assertEqual(dn.po_no, si.po_no) + self.assertEqual(dn.get("items")[0].billed_amt, 1000) self.assertEqual(dn.per_billed, 100) self.assertEqual(dn.status, "Completed") diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 8538b00fd6e..85b6d578bbd 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -500,6 +500,8 @@ def update_billed_amount_based_on_po(po_detail, update_modified=True): @frappe.whitelist() def make_purchase_invoice(source_name, target_doc=None): from frappe.model.mapper import get_mapped_doc + from erpnext.accounts.party import get_payment_terms_template + doc = frappe.get_doc('Purchase Receipt', source_name) returned_qty_map = get_returned_qty_map(source_name) invoiced_qty_map = get_invoiced_qty_map(source_name) @@ -510,6 +512,7 @@ def make_purchase_invoice(source_name, target_doc=None): doc = frappe.get_doc(target) doc.ignore_pricing_rule = 1 + doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company) doc.run_method("onload") doc.run_method("set_missing_values") doc.run_method("calculate_taxes_and_totals") @@ -536,7 +539,8 @@ def make_purchase_invoice(source_name, target_doc=None): "doctype": "Purchase Invoice", "field_map": { "supplier_warehouse":"supplier_warehouse", - "is_return": "is_return" + "is_return": "is_return", + "bill_date": "bill_date" }, "validation": { "docstatus": ["=", 1], diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index d0208d01eda..26bcd457449 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -20,6 +20,30 @@ class TestPurchaseReceipt(unittest.TestCase): frappe.db.set_value("Buying Settings", None, "allow_multiple_items", 1) def test_make_purchase_invoice(self): + if not frappe.db.exists('Payment Terms Template', '_Test Payment Terms Template For Purchase Invoice'): + frappe.get_doc({ + 'doctype': 'Payment Terms Template', + 'template_name': '_Test Payment Terms Template For Purchase Invoice', + 'allocate_payment_based_on_payment_terms': 1, + 'terms': [ + { + 'doctype': 'Payment Terms Template Detail', + 'invoice_portion': 50.00, + 'credit_days_based_on': 'Day(s) after invoice date', + 'credit_days': 00 + }, + { + 'doctype': 'Payment Terms Template Detail', + 'invoice_portion': 50.00, + 'credit_days_based_on': 'Day(s) after invoice date', + 'credit_days': 30 + }] + }).insert() + + template = frappe.db.get_value('Payment Terms Template', '_Test Payment Terms Template For Purchase Invoice') + old_template_in_supplier = frappe.db.get_value("Supplier", "_Test Supplier", "payment_terms") + frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", template) + pr = make_purchase_receipt(do_not_save=True) self.assertRaises(frappe.ValidationError, make_purchase_invoice, pr.name) pr.submit() @@ -29,10 +53,23 @@ class TestPurchaseReceipt(unittest.TestCase): self.assertEqual(pi.doctype, "Purchase Invoice") self.assertEqual(len(pi.get("items")), len(pr.get("items"))) - # modify rate + # test maintaining same rate throughout purchade cycle pi.get("items")[0].rate = 200 self.assertRaises(frappe.ValidationError, frappe.get_doc(pi).submit) + # test if payment terms are fetched and set in PI + self.assertEqual(pi.payment_terms_template, template) + self.assertEqual(pi.payment_schedule[0].payment_amount, flt(pi.grand_total)/2) + self.assertEqual(pi.payment_schedule[0].invoice_portion, 50) + self.assertEqual(pi.payment_schedule[1].payment_amount, flt(pi.grand_total)/2) + self.assertEqual(pi.payment_schedule[1].invoice_portion, 50) + + # teardown + pi.delete() # draft PI + pr.cancel() + frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", old_template_in_supplier) + frappe.get_doc('Payment Terms Template', '_Test Payment Terms Template For Purchase Invoice').delete() + def test_purchase_receipt_no_gl_entry(self): company = frappe.db.get_value('Warehouse', '_Test Warehouse - _TC', 'company') diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 3bed73e9258..b33b4be1979 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -83,7 +83,7 @@ class StockEntry(StockController): self.set_incoming_rate() self.validate_serialized_batch() self.set_actual_qty() - self.calculate_rate_and_amount(update_finished_item_rate=False) + self.calculate_rate_and_amount() def on_submit(self): @@ -117,6 +117,7 @@ class StockEntry(StockController): self.update_transferred_qty() self.update_quality_inspection() self.delete_auto_created_batches() + self.delete_linked_stock_entry() def set_job_card_data(self): if self.job_card and not self.work_order: @@ -160,6 +161,12 @@ class StockEntry(StockController): frappe.throw(_("For job card {0}, you can only make the 'Material Transfer for Manufacture' type stock entry") .format(self.job_card)) + def delete_linked_stock_entry(self): + if self.purpose == "Send to Warehouse": + for d in frappe.get_all("Stock Entry", filters={"docstatus": 0, + "outgoing_stock_entry": self.name, "purpose": "Receive at Warehouse"}): + frappe.delete_doc("Stock Entry", d.name) + def set_transfer_qty(self): for item in self.get("items"): if not flt(item.qty): @@ -1113,7 +1120,10 @@ class StockEntry(StockController): for d in backflushed_materials.get(item.item_code): if d.get(item.warehouse): if (qty > req_qty): - qty-= d.get(item.warehouse) + qty = (qty/trans_qty) * flt(self.fg_completed_qty) + + if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')): + qty = frappe.utils.ceil(qty) if qty > 0: self.add_to_stock_entry_detail({ @@ -1194,8 +1204,6 @@ class StockEntry(StockController): return item_dict def add_to_stock_entry_detail(self, item_dict, bom_no=None): - cost_center = frappe.db.get_value("Company", self.company, 'cost_center') - for d in item_dict: stock_uom = item_dict[d].get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom") @@ -1206,9 +1214,10 @@ class StockEntry(StockController): se_child.uom = item_dict[d]["uom"] if item_dict[d].get("uom") else stock_uom se_child.stock_uom = stock_uom se_child.qty = flt(item_dict[d]["qty"], se_child.precision("qty")) - se_child.cost_center = item_dict[d].get("cost_center") or cost_center se_child.allow_alternative_item = item_dict[d].get("allow_alternative_item", 0) se_child.subcontracted_item = item_dict[d].get("main_item_code") + se_child.cost_center = (item_dict[d].get("cost_center") or + get_default_cost_center(item_dict[d], company = self.company)) for field in ["idx", "po_detail", "original_item", "expense_account", "description", "item_name"]: diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 2b4780437f4..ed29316030f 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -191,7 +191,7 @@ class StockReconciliation(StockController): sl_entries = [] - serialized_items = False + serialized_items = [] for row in self.items: item = frappe.get_cached_doc("Item", row.item_code) if not (item.has_serial_no): @@ -229,27 +229,29 @@ class StockReconciliation(StockController): sl_entries.append(sle_data) else: - serialized_items = True + serialized_items.append(row.item_code) if serialized_items: - self.get_sle_for_serialized_items(sl_entries) + self.get_sle_for_serialized_items(sl_entries, serialized_items) if sl_entries: allow_negative_stock = frappe.get_cached_value("Stock Settings", None, "allow_negative_stock") self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock) - def get_sle_for_serialized_items(self, sl_entries): - self.issue_existing_serial_and_batch(sl_entries) - self.add_new_serial_and_batch(sl_entries) - self.update_valuation_rate_for_serial_no() + def get_sle_for_serialized_items(self, sl_entries, serialized_items=[]): + self.issue_existing_serial_and_batch(sl_entries, serialized_items) + self.add_new_serial_and_batch(sl_entries, serialized_items) + self.update_valuation_rate_for_serial_no(serialized_items) if sl_entries: sl_entries = self.merge_similar_item_serial_nos(sl_entries) - def issue_existing_serial_and_batch(self, sl_entries): + def issue_existing_serial_and_batch(self, sl_entries, serialized_items=[]): from erpnext.stock.stock_ledger import get_stock_ledger_entries for row in self.items: + if row.item_code not in serialized_items: continue + serial_nos = get_serial_nos(row.serial_no) or [] # To issue existing serial nos @@ -303,8 +305,10 @@ class StockReconciliation(StockController): sl_entries.append(new_args) - def add_new_serial_and_batch(self, sl_entries): + def add_new_serial_and_batch(self, sl_entries, serialized_items=[]): for row in self.items: + if row.item_code not in serialized_items: continue + if row.qty: args = self.get_sle_for_items(row) @@ -316,9 +320,9 @@ class StockReconciliation(StockController): sl_entries.append(args) - def update_valuation_rate_for_serial_no(self): + def update_valuation_rate_for_serial_no(self, serialized_items=[]): for d in self.items: - if not d.serial_no: continue + if d.item_code not in serialized_items: continue serial_nos = get_serial_nos(d.serial_no) self.update_valuation_rate_for_serial_nos(d, serial_nos) @@ -372,7 +376,16 @@ class StockReconciliation(StockController): where voucher_type=%s and voucher_no=%s""", (self.doctype, self.name)) sl_entries = [] - self.get_sle_for_serialized_items(sl_entries) + + serialized_items = [] + + for row in self.items: + has_serial_no = frappe.get_cached_value("Item", row.item_code, "has_serial_no") + if has_serial_no: + serialized_items.append(row.item_code) + + if serialized_items: + self.get_sle_for_serialized_items(sl_entries, serialized_items) if sl_entries: sl_entries.reverse() diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 27908016407..7c55fd6da1c 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -207,9 +207,9 @@ class TestStockReconciliation(unittest.TestCase): def test_stock_reco_for_serial_and_batch_item(self): set_perpetual_inventory() - item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item'}) + item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item 1'}) if not item: - item = create_item("Batched and Serialised Item") + item = create_item("Batched and Serialised Item 1") item.has_batch_no = 1 item.create_new_batch = 1 item.has_serial_no = 1 @@ -217,7 +217,7 @@ class TestStockReconciliation(unittest.TestCase): item.serial_no_series = "S-.####" item.save() else: - item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item'}) + item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item 1'}) warehouse = "_Test Warehouse for Stock Reco2 - _TC" @@ -236,7 +236,7 @@ class TestStockReconciliation(unittest.TestCase): self.assertEqual(frappe.db.exists("Batch", batch_no), None) if frappe.db.exists("Serial No", serial_nos[0]): - frappe.delete_doc("Serial No", serial_nos[0]) + frappe.delete_doc("Serial No", serial_nos[0]) def test_stock_reco_for_serial_and_batch_item_with_future_dependent_entry(self): """ @@ -255,9 +255,9 @@ class TestStockReconciliation(unittest.TestCase): set_perpetual_inventory() - item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item'}) + item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item 1'}) if not item: - item = create_item("Batched and Serialised Item") + item = create_item("Batched and Serialised Item 1") item.has_batch_no = 1 item.create_new_batch = 1 item.has_serial_no = 1 @@ -265,7 +265,7 @@ class TestStockReconciliation(unittest.TestCase): item.serial_no_series = "S-.####" item.save() else: - item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item'}) + item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item 1'}) warehouse = "_Test Warehouse for Stock Reco2 - _TC" @@ -392,6 +392,35 @@ class TestStockReconciliation(unittest.TestCase): doc.cancel() frappe.delete_doc(doc.doctype, doc.name) + def test_stock_reco_with_serial_and_batch(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + warehouse = "_Test Warehouse for Stock Reco1 - _TC" + ste1=make_stock_entry(item_code="Stock-Reco-Serial-Item-1", + target=warehouse, qty=2, basic_rate=100) + + ste2=make_stock_entry(item_code="Stock-Reco-batch-Item-1", + target=warehouse, qty=2, basic_rate=100) + + sr = create_stock_reconciliation(item_code="Stock-Reco-Serial-Item-1", + warehouse = warehouse, rate=200, do_not_submit=True) + + sr.append("items", { + "item_code": "Stock-Reco-batch-Item-1", + "warehouse": warehouse, + "batch_no": ste2.items[0].batch_no, + "valuation_rate": 200 + }) + + sr.submit() + sle = frappe.get_all("Stock Ledger Entry", filters={"item_code": "Stock-Reco-batch-Item-1", + "warehouse": warehouse, "voucher_no": sr.name, "voucher_type": sr.doctype}) + + self.assertEquals(len(sle), 1) + + for doc in [sr, ste2, ste1]: + doc.cancel() + def insert_existing_sle(warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index b1e38b340c5..48b4dc858c4 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -527,23 +527,40 @@ def get_default_deferred_account(args, item, fieldname=None): else: return None -def get_default_cost_center(args, item, item_group, brand, company=None): +def get_default_cost_center(args, item=None, item_group=None, brand=None, company=None): cost_center = None + + if not company and args.get("company"): + company = args.get("company") + if args.get('project'): cost_center = frappe.db.get_value("Project", args.get("project"), "cost_center", cache=True) - if not cost_center: + if not cost_center and (item and item_group and brand): if args.get('customer'): cost_center = item.get('selling_cost_center') or item_group.get('selling_cost_center') or brand.get('selling_cost_center') else: cost_center = item.get('buying_cost_center') or item_group.get('buying_cost_center') or brand.get('buying_cost_center') - cost_center = cost_center or args.get("cost_center") + elif not cost_center and args.get("item_code") and company: + for method in ["get_item_defaults", "get_item_group_defaults", "get_brand_defaults"]: + path = "erpnext.stock.get_item_details.{0}".format(method) + data = frappe.get_attr(path)(args.get("item_code"), company) + + if data and (data.selling_cost_center or data.buying_cost_center): + return data.selling_cost_center or data.buying_cost_center + + if not cost_center and args.get("cost_center"): + cost_center = args.get("cost_center") if (company and cost_center and frappe.get_cached_value("Cost Center", cost_center, "company") != company): return None + if not cost_center and company: + cost_center = frappe.get_cached_value("Company", + company, "cost_center") + return cost_center def get_default_supplier(args, item, item_group, brand): diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 953939bccb8..a804917381f 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -21,7 +21,7 @@ def execute(filters=None): fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) details = item_dict["details"] - if not fifo_queue and (not item_dict.get("total_qty")): continue + if not fifo_queue: continue average_age = get_average_age(fifo_queue, to_date) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 042087a4a77..145562cdc1b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -164,7 +164,7 @@ def get_stock_ledger_entries(filters, items): select sle.item_code, warehouse, sle.posting_date, sle.actual_qty, sle.valuation_rate, sle.company, sle.voucher_type, sle.qty_after_transaction, sle.stock_value_difference, - sle.item_code as name, sle.voucher_no + sle.item_code as name, sle.voucher_no, sle.stock_value from `tabStock Ledger Entry` sle force index (posting_sort_index) where sle.docstatus < 2 %s %s @@ -196,7 +196,7 @@ def get_item_warehouse_map(filters, sle): else: qty_diff = flt(d.actual_qty) - value_diff = flt(d.stock_value_difference) + value_diff = flt(d.stock_value) - flt(qty_dict.bal_val) if d.posting_date < from_date: qty_dict.opening_qty += qty_diff diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index db39bae8a63..da4b529b01e 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -284,7 +284,6 @@ def update_included_uom_in_report(columns, result, include_uom, conversion_facto return convertible_cols = {} - is_dict_obj = False if isinstance(result[0], dict): is_dict_obj = True @@ -306,13 +305,13 @@ def update_included_uom_in_report(columns, result, include_uom, conversion_facto for row_idx, row in enumerate(result): data = row.items() if is_dict_obj else enumerate(row) for key, value in data: - if not key in convertible_columns or not conversion_factors[row_idx]: + if key not in convertible_columns or not conversion_factors[row_idx-1]: continue if convertible_columns.get(key) == 'rate': - new_value = flt(value) * conversion_factors[row_idx] + new_value = flt(value) * conversion_factors[row_idx-1] else: - new_value = flt(value) / conversion_factors[row_idx] + new_value = flt(value) / conversion_factors[row_idx-1] if not is_dict_obj: row.insert(key+1, new_value)