From f60e040d69aa56f71a632b3d77474f60657be689 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 18 Jan 2022 21:15:27 +0100 Subject: [PATCH 001/112] feat: option to disable Item Tax Template --- .../item_tax_template/item_tax_template.json | 22 +++++++++++++++++-- erpnext/controllers/queries.py | 5 +++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json index 77c9e95b75..b42d712d88 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "allow_rename": 1, - "creation": "2018-11-22 22:45:00.370913", + "creation": "2022-01-19 01:09:13.297137", "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, @@ -10,6 +10,9 @@ "field_order": [ "title", "company", + "column_break_3", + "disabled", + "section_break_5", "taxes" ], "fields": [ @@ -36,10 +39,24 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" } ], "links": [], - "modified": "2021-03-08 19:50:21.416513", + "modified": "2022-01-18 21:11:23.105589", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template", @@ -82,6 +99,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index dc04dab84c..d0a3d4aaac 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -707,6 +707,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): item_doc = frappe.get_cached_doc('Item', filters.get('item_code')) item_group = filters.get('item_group') + company = filters.get('company') taxes = item_doc.taxes or [] while item_group: @@ -715,7 +716,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): item_group = item_group_doc.parent_item_group if not taxes: - return frappe.db.sql(""" SELECT name FROM `tabItem Tax Template` """) + return frappe.get_all('Item Tax Template', filters={'disabled': 0, 'company': company}, as_list=True) else: valid_from = filters.get('valid_from') valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from @@ -724,7 +725,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): 'item_code': filters.get('item_code'), 'posting_date': valid_from, 'tax_category': filters.get('tax_category'), - 'company': filters.get('company') + 'company': company } taxes = _get_item_tax_template(args, taxes, for_validate=True) From 663c594ead54ced046887bbd94a6a0c88fa7b8e8 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 18 Jan 2022 21:16:29 +0100 Subject: [PATCH 002/112] feat: option to disable tax category --- .../doctype/tax_category/tax_category.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/tax_category/tax_category.json b/erpnext/accounts/doctype/tax_category/tax_category.json index f7145af44c..44a339f31d 100644 --- a/erpnext/accounts/doctype/tax_category/tax_category.json +++ b/erpnext/accounts/doctype/tax_category/tax_category.json @@ -2,12 +2,13 @@ "actions": [], "allow_rename": 1, "autoname": "field:title", - "creation": "2018-11-22 23:38:39.668804", + "creation": "2022-01-19 01:09:28.920486", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "title" + "title", + "disabled" ], "fields": [ { @@ -18,14 +19,21 @@ "label": "Title", "reqd": 1, "unique": 1 + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-03 11:50:38.748872", + "modified": "2022-01-18 21:13:41.161017", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Category", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -65,5 +73,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From f6dda738dc99060090e703b21f7a77692887605b Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 20 Jan 2022 12:22:56 +0530 Subject: [PATCH 003/112] fix: ignore pricing rule in all transactions --- erpnext/accounts/doctype/pricing_rule/pricing_rule.py | 4 +++- erpnext/public/js/controllers/transaction.js | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index ac96b045a2..93c68f07ce 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -249,7 +249,8 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa "free_item_data": [], "parent": args.parent, "parenttype": args.parenttype, - "child_docname": args.get('child_docname') + "child_docname": args.get('child_docname'), + "price_list_rate": args.get('price_list_rate') }) if args.ignore_pricing_rule or not args.item_code: @@ -403,6 +404,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): if pricing_rule.rate_or_discount == 'Discount Percentage': item_details.discount_percentage = 0.0 item_details.discount_amount = 0.0 + item_details.rate = item_details.get('price_list_rate', 0) if pricing_rule.rate_or_discount == 'Discount Amount': item_details.discount_amount = 0.0 diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 3791741663..65ccd1f11f 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1463,7 +1463,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "item_code": d.item_code, "pricing_rules": d.pricing_rules, "parenttype": d.parenttype, - "parent": d.parent + "parent": d.parent, + "price_list_rate": d.price_list_rate }) } }); From b8c41e303035993d98aeb406865052a968335afe Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 20 Jan 2022 13:06:08 +0530 Subject: [PATCH 004/112] test: item price on remove pricing rule --- .../doctype/pricing_rule/pricing_rule.py | 1 + .../doctype/pricing_rule/test_pricing_rule.py | 41 +++++++++++++++++++ erpnext/controllers/accounts_controller.py | 8 ++++ 3 files changed, 50 insertions(+) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 93c68f07ce..65ded03673 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -423,6 +423,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): item_details.applied_on_items = ','.join(items) item_details.pricing_rules = '' + item_details.pricing_rule_removed = True return item_details diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 5746a840f3..2a2b1cf681 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -628,6 +628,47 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() + def test_remove_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", + "price_or_product_discount": "Price", + "items": [{ + "item_code": "Water Flask", + }], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Discount Percentage", + "discount_percentage": 20, + "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() + + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].discount_percentage, 20) + self.assertEqual(si.items[0].rate, 80) + + si.ignore_pricing_rule = 1 + si.save() + + self.assertEqual(si.items[0].discount_percentage, 0) + self.assertEqual(si.items[0].rate, 100) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() + item.delete() + + test_dependencies = ["Campaign"] def make_pricing_rule(**args): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4775f56a01..27e882e8d5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -402,6 +402,14 @@ class AccountsController(TransactionBase): if item_qty != len(get_serial_nos(item.get('serial_no'))): item.set(fieldname, value) + elif ret.get("pricing_rule_removed") and value is not None \ + and fieldname in [ + 'discount_percentage', 'discount_amount', 'rate', + 'margin_rate_or_amount', 'margin_type', 'remove_free_item' + ]: + # reset pricing rule fields if pricing_rule_removed + item.set(fieldname, value) + if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'): item.set('is_fixed_asset', ret.get('is_fixed_asset', 0)) From c68c70f8bc88d9b05d64774ba070a34c059b7d30 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 25 Jan 2022 19:59:33 +0530 Subject: [PATCH 005/112] feat: Refund entry against loans --- erpnext/loan_management/doctype/loan/loan.js | 21 +++++++- .../loan_management/doctype/loan/loan.json | 15 +++++- erpnext/loan_management/doctype/loan/loan.py | 48 ++++++++++++++++--- .../loan_management/doctype/loan/test_loan.py | 24 ++++++++-- .../loan_disbursement/loan_disbursement.py | 4 +- .../doctype/loan_type/loan_type.js | 2 +- .../doctype/loan_type/loan_type.json | 18 +++++-- erpnext/patches.txt | 1 + .../v13_0/update_disbursement_account.py | 22 +++++++++ 9 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 erpnext/patches/v13_0/update_disbursement_account.py diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js index f9c201ab60..940a1bbc00 100644 --- a/erpnext/loan_management/doctype/loan/loan.js +++ b/erpnext/loan_management/doctype/loan/loan.js @@ -46,7 +46,7 @@ frappe.ui.form.on('Loan', { }); }); - $.each(["payment_account", "loan_account"], function (i, field) { + $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) { frm.set_query(field, function () { return { "filters": { @@ -88,6 +88,10 @@ frappe.ui.form.on('Loan', { frm.add_custom_button(__('Loan Write Off'), function() { frm.trigger("make_loan_write_off_entry"); },__('Create')); + + frm.add_custom_button(__('Loan Refund'), function() { + frm.trigger("make_loan_refund"); + },__('Create')); } } frm.trigger("toggle_fields"); @@ -155,6 +159,21 @@ frappe.ui.form.on('Loan', { }) }, + make_loan_refund: function(frm) { + frappe.call({ + args: { + "loan": frm.doc.name + }, + method: "erpnext.loan_management.doctype.loan.loan.make_refund_jv", + callback: function (r) { + if (r.message) { + let doc = frappe.model.sync(r.message)[0]; + frappe.set_route("Form", doc.doctype, doc.name); + } + } + }) + }, + request_loan_closure: function(frm) { frappe.confirm(__("Do you really want to close this loan"), function() { diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index af26f7bc5c..196f36f0f4 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "autoname": "ACC-LOAN-.YYYY.-.#####", - "creation": "2019-08-29 17:29:18.176786", + "creation": "2022-01-25 10:30:02.294967", "doctype": "DocType", "document_type": "Document", "editable_grid": 1, @@ -34,6 +34,7 @@ "is_term_loan", "account_info", "mode_of_payment", + "disbursement_account", "payment_account", "column_break_9", "loan_account", @@ -356,12 +357,21 @@ "fieldtype": "Date", "label": "Closure Date", "read_only": 1 + }, + { + "fetch_from": "loan_type.disbursement_account", + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "read_only": 1, + "reqd": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-10-12 18:10:32.360818", + "modified": "2022-01-25 16:29:16.325501", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", @@ -391,5 +401,6 @@ "search_fields": "posting_date", "sort_field": "creation", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index f660a24a6d..b798e088b4 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -10,6 +10,7 @@ from frappe import _ from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate import erpnext +from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry from erpnext.controllers.accounts_controller import AccountsController from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import ( @@ -233,17 +234,15 @@ def request_loan_closure(loan, posting_date=None): loan_type = frappe.get_value('Loan', loan, 'loan_type') write_off_limit = frappe.get_value('Loan Type', loan_type, 'write_off_amount') - # checking greater than 0 as there may be some minor precision error - if not pending_amount: - frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') - elif pending_amount < write_off_limit: + if pending_amount and abs(pending_amount) < write_off_limit: # Auto create loan write off and update status as loan closure requested write_off = make_loan_write_off(loan) write_off.submit() - frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') - else: + elif pending_amount > 0: frappe.throw(_("Cannot close loan as there is an outstanding of {0}").format(pending_amount)) + frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') + @frappe.whitelist() def get_loan_application(loan_application): loan = frappe.get_doc("Loan Application", loan_application) @@ -400,4 +399,39 @@ def add_single_month(date): if getdate(date) == get_last_day(date): return get_last_day(add_months(date, 1)) else: - return add_months(date, 1) \ No newline at end of file + return add_months(date, 1) + +@frappe.whitelist() +def make_refund_jv(loan, amount=0, reference_number=None, reference_date=None, submit=0): + loan_details = frappe.db.get_value('Loan', loan, ['applicant_type', 'applicant', + 'loan_account', 'payment_account', 'posting_date', 'company', 'name', + 'total_payment', 'total_principal_paid'], as_dict=1) + + loan_details.doctype = 'Loan' + loan_details[loan_details.applicant_type.lower()] = loan_details.applicant + + if not amount: + amount = flt(loan_details.total_principal_paid - loan_details.total_payment) + + if amount < 0: + frappe.throw(_('No excess amount pending for refund')) + + refund_jv = get_payment_entry(loan_details, { + "party_type": loan_details.applicant_type, + "party_account": loan_details.loan_account, + "amount_field_party": 'debit_in_account_currency', + "amount_field_bank": 'credit_in_account_currency', + "amount": amount, + "bank_account": loan_details.payment_account + }) + + if reference_number: + refund_jv.cheque_no = reference_number + + if reference_date: + refund_jv.cheque_date = reference_date + + if submit: + refund_jv.submit() + + return refund_jv \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index 1676c218c8..6415689782 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -42,16 +42,17 @@ class TestLoan(unittest.TestCase): create_loan_type("Personal Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', penalty_income_account='Penalty Income Account - _TC') - create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() @@ -790,6 +791,18 @@ def create_loan_accounts(): "account_type": "Bank", }).insert(ignore_permissions=True) + if not frappe.db.exists("Account", "Disbursement Account - _TC"): + frappe.get_doc({ + "doctype": "Account", + "company": "_Test Company", + "account_name": "Disbursement Account", + "root_type": "Asset", + "report_type": "Balance Sheet", + "currency": "INR", + "parent_account": "Bank Accounts - _TC", + "account_type": "Bank", + }).insert(ignore_permissions=True) + if not frappe.db.exists("Account", "Interest Income Account - _TC"): frappe.get_doc({ "doctype": "Account", @@ -815,7 +828,7 @@ def create_loan_accounts(): }).insert(ignore_permissions=True) def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_interest_rate=None, is_term_loan=None, grace_period_in_days=None, - mode_of_payment=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None, + mode_of_payment=None, disbursement_account=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None, repayment_method=None, repayment_periods=None): if not frappe.db.exists("Loan Type", loan_name): @@ -829,6 +842,7 @@ def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_i "penalty_interest_rate": penalty_interest_rate, "grace_period_in_days": grace_period_in_days, "mode_of_payment": mode_of_payment, + "disbursement_account": disbursement_account, "payment_account": payment_account, "loan_account": loan_account, "interest_income_account": interest_income_account, diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index e2d758b1b9..df3aadfb18 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -122,7 +122,7 @@ class LoanDisbursement(AccountsController): gle_map.append( self.get_gl_dict({ "account": loan_details.loan_account, - "against": loan_details.payment_account, + "against": loan_details.disbursement_account, "debit": self.disbursed_amount, "debit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", @@ -137,7 +137,7 @@ class LoanDisbursement(AccountsController): gle_map.append( self.get_gl_dict({ - "account": loan_details.payment_account, + "account": loan_details.disbursement_account, "against": loan_details.loan_account, "credit": self.disbursed_amount, "credit_in_account_currency": self.disbursed_amount, diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.js b/erpnext/loan_management/doctype/loan_type/loan_type.js index 04c89c4549..9f9137cfbc 100644 --- a/erpnext/loan_management/doctype/loan_type/loan_type.js +++ b/erpnext/loan_management/doctype/loan_type/loan_type.js @@ -15,7 +15,7 @@ frappe.ui.form.on('Loan Type', { }); }); - $.each(["payment_account", "loan_account"], function (i, field) { + $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) { frm.set_query(field, function () { return { "filters": { diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.json b/erpnext/loan_management/doctype/loan_type/loan_type.json index c0a5d2cda1..00337e4b4c 100644 --- a/erpnext/loan_management/doctype/loan_type/loan_type.json +++ b/erpnext/loan_management/doctype/loan_type/loan_type.json @@ -19,9 +19,10 @@ "description", "account_details_section", "mode_of_payment", + "disbursement_account", "payment_account", - "loan_account", "column_break_12", + "loan_account", "interest_income_account", "penalty_income_account", "amended_from" @@ -79,7 +80,7 @@ { "fieldname": "payment_account", "fieldtype": "Link", - "label": "Payment Account", + "label": "Repayment Account", "options": "Account", "reqd": 1 }, @@ -149,15 +150,23 @@ "fieldtype": "Currency", "label": "Auto Write Off Amount ", "options": "Company:company:default_currency" + }, + { + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "reqd": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:10:57.368490", + "modified": "2022-01-25 16:23:57.009349", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Type", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -181,5 +190,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5190f9f8c6..1683dabccc 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -325,3 +325,4 @@ erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning erpnext.patches.v14_0.delete_agriculture_doctypes erpnext.patches.v13_0.update_exchange_rate_settings +erpnext.patches.v13_0.update_disbursement_account diff --git a/erpnext/patches/v13_0/update_disbursement_account.py b/erpnext/patches/v13_0/update_disbursement_account.py new file mode 100644 index 0000000000..c56fa8fdc6 --- /dev/null +++ b/erpnext/patches/v13_0/update_disbursement_account.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + + frappe.reload_doc("loan_management", "doctype", "loan_type") + frappe.reload_doc("loan_management", "doctype", "loan") + + loan_type = frappe.qb.DocType("Loan Type") + loan = frappe.qb.DocType("Loan") + + frappe.qb.update( + loan_type + ).set( + loan_type.disbursement_account, loan_type.payment_account + ).run() + + frappe.qb.update( + loan + ).set( + loan.disbursement_account, loan.payment_account + ).run() \ No newline at end of file From b50036c04a116b2a3aa1784daf161a2f618765a8 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Wed, 12 Jan 2022 16:10:57 +0530 Subject: [PATCH 006/112] fix: consider returned_qty while updating billed_amt (cherry picked from commit 63aaa1e357280b24c537a502a479f7bb7a6654e4) --- .../doctype/delivery_note/delivery_note.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index d1e22440b9..9ee1802917 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -340,15 +340,25 @@ class DeliveryNote(SellingController): def update_billed_amount_based_on_so(so_detail, update_modified=True): # Billed against Sales Order directly - billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item` - where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail) + billed_against_so = frappe.db.sql("""select sum(si_item.amount) + from `tabSales Invoice Item` si_item, `tabSales Invoice` si + where + si_item.parent = si.name + and si_item.so_detail=%s + and (si_item.dn_detail is null or si_item.dn_detail = '') + and si_item.docstatus=1 + and si.update_stock = 0 + """, so_detail) billed_against_so = billed_against_so and billed_against_so[0][0] or 0 # Get all Delivery Note Item rows against the Sales Order Item row - dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent + dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn - where dn.name=dn_item.parent and dn_item.so_detail=%s - and dn.docstatus=1 and dn.is_return = 0 + where + dn.name = dn_item.parent + and dn_item.so_detail=%s + and dn.docstatus=1 + and dn.is_return = 0 order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1) updated_dn = [] @@ -367,7 +377,11 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True): # Distribute billed amount directly against SO between DNs based on FIFO if billed_against_so and billed_amt_agianst_dn < dnd.amount: - pending_to_bill = flt(dnd.amount) - billed_amt_agianst_dn + if dnd.returned_qty: + pending_to_bill = flt(dnd.amount) * (dnd.stock_qty - dnd.returned_qty) / dnd.stock_qty + else: + pending_to_bill = flt(dnd.amount) + pending_to_bill -= billed_amt_agianst_dn if pending_to_bill <= billed_against_so: billed_amt_agianst_dn += pending_to_bill billed_against_so -= pending_to_bill From 1fd85398733f3f802b629e04c89dbf2c45237982 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Wed, 12 Jan 2022 16:13:06 +0530 Subject: [PATCH 007/112] fix: check so_detail before dn_detail (cherry picked from commit 5de6b8dc4df407fd953efe69640e22bd4ea90b6e) --- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index bc443581e4..4fa77186a0 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1249,14 +1249,14 @@ class SalesInvoice(SellingController): def update_billing_status_in_dn(self, update_modified=True): updated_delivery_notes = [] for d in self.get("items"): - if d.dn_detail: + if d.so_detail: + updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) + elif d.dn_detail: billed_amt = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item` where dn_detail=%s and docstatus=1""", d.dn_detail) billed_amt = billed_amt and billed_amt[0][0] or 0 frappe.db.set_value("Delivery Note Item", d.dn_detail, "billed_amt", billed_amt, update_modified=update_modified) updated_delivery_notes.append(d.delivery_note) - elif d.so_detail: - updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) for dn in set(updated_delivery_notes): frappe.get_doc("Delivery Note", dn).update_billing_percentage(update_modified=update_modified) From edbf52ab951f80e09b17d5cf348e5b2c91ed87d9 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Fri, 14 Jan 2022 16:15:26 +0530 Subject: [PATCH 008/112] feat: add patch (cherry picked from commit fc65a3d9895c8ba9de957da820ed6b59c6c1bcbd) # Conflicts: # erpnext/patches.txt --- erpnext/patches.txt | 4 ++++ .../v13_0/set_billed_amount_in_returned_dn.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5a8f8ef6f8..5f95e13089 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -324,6 +324,7 @@ erpnext.patches.v13_0.update_tax_category_for_rcm execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning +<<<<<<< HEAD erpnext.patches.v13_0.hospitality_deprecation_warning erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v13_0.update_asset_quantity_field @@ -342,3 +343,6 @@ erpnext.patches.v14_0.restore_einvoice_fields erpnext.patches.v13_0.update_sane_transfer_against erpnext.patches.v12_0.add_company_link_to_einvoice_settings erpnext.patches.v14_0.migrate_cost_center_allocations +======= +erpnext.patches.v13_0.set_billed_amount_in_returned_dn +>>>>>>> fc65a3d989 (feat: add patch) diff --git a/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py b/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py new file mode 100644 index 0000000000..1f86c76d14 --- /dev/null +++ b/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py @@ -0,0 +1,22 @@ +# Copyright (c) 2022, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so + + +def execute(): + dn_item = frappe.qb.DocType('Delivery Note Item') + + so_detail_list = (frappe.qb.from_(dn_item) + .select(dn_item.so_detail) + .where( + (dn_item.so_detail.notnull()) & + (dn_item.so_detail != '') & + (dn_item.docstatus == 1) & + (dn_item.returned_qty > 0) + )).run() + + for so_detail in so_detail_list: + update_billed_amount_based_on_so(so_detail[0], False) \ No newline at end of file From ce0b84f54d495fc78a6792a9b05d0eb1dc799ed2 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Fri, 14 Jan 2022 19:21:52 +0530 Subject: [PATCH 009/112] refactor: use frappe.qb instead of sql (cherry picked from commit 0a9ec9f591f8b4d0e630a3c902b69c9996f080dd) --- .../doctype/delivery_note/delivery_note.py | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 9ee1802917..c3247fbe3e 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -339,27 +339,35 @@ class DeliveryNote(SellingController): frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again")) def update_billed_amount_based_on_so(so_detail, update_modified=True): + from frappe.query_builder.functions import Sum + # Billed against Sales Order directly - billed_against_so = frappe.db.sql("""select sum(si_item.amount) - from `tabSales Invoice Item` si_item, `tabSales Invoice` si - where - si_item.parent = si.name - and si_item.so_detail=%s - and (si_item.dn_detail is null or si_item.dn_detail = '') - and si_item.docstatus=1 - and si.update_stock = 0 - """, so_detail) + si = frappe.qb.DocType("Sales Invoice").as_("si") + si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") + sum_amount = Sum(si_item.amount).as_("amount") + + billed_against_so = frappe.qb.from_(si).from_(si_item).select(sum_amount).where( + (si_item.parent == si.name) & + (si_item.so_detail == so_detail) & + ((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) & + (si_item.docstatus == 1) & + (si.update_stock == 0) + ).run() billed_against_so = billed_against_so and billed_against_so[0][0] or 0 # Get all Delivery Note Item rows against the Sales Order Item row - dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty - from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn - where - dn.name = dn_item.parent - and dn_item.so_detail=%s - and dn.docstatus=1 - and dn.is_return = 0 - order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1) + + dn = frappe.qb.DocType("Delivery Note").as_("dn") + dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item") + + dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty).where( + (dn.name == dn_item.parent) & + (dn_item.so_detail == so_detail) & + (dn.docstatus == 1) & + (dn.is_return == 0) + ).orderby( + dn.posting_date, dn.posting_time, dn.name + ).run(as_dict=True) updated_dn = [] for dnd in dn_details: From c53cdce1c08e137bc9e06417ad57f0365e388b62 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Mon, 31 Jan 2022 19:07:07 +0530 Subject: [PATCH 010/112] chore: remove patch (cherry picked from commit fedeb2a70f80a39d31cb928d9876fbc94f27561c) # Conflicts: # erpnext/patches.txt --- erpnext/patches.txt | 4 ++++ .../v13_0/set_billed_amount_in_returned_dn.py | 22 ------------------- 2 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5f95e13089..ffd85a6351 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -325,6 +325,10 @@ execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings' erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning <<<<<<< HEAD +<<<<<<< HEAD +======= +erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit +>>>>>>> fedeb2a70f (chore: remove patch) erpnext.patches.v13_0.hospitality_deprecation_warning erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v13_0.update_asset_quantity_field diff --git a/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py b/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py deleted file mode 100644 index 1f86c76d14..0000000000 --- a/erpnext/patches/v13_0/set_billed_amount_in_returned_dn.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2022, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe - -from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so - - -def execute(): - dn_item = frappe.qb.DocType('Delivery Note Item') - - so_detail_list = (frappe.qb.from_(dn_item) - .select(dn_item.so_detail) - .where( - (dn_item.so_detail.notnull()) & - (dn_item.so_detail != '') & - (dn_item.docstatus == 1) & - (dn_item.returned_qty > 0) - )).run() - - for so_detail in so_detail_list: - update_billed_amount_based_on_so(so_detail[0], False) \ No newline at end of file From 9bd56b0f79af4970ce6c1762d647725fba4ebbf9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 1 Feb 2022 14:14:04 +0530 Subject: [PATCH 011/112] fix: typeerror on invoice creation from SO/PO --- erpnext/controllers/accounts_controller.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index eab9e12641..29c2633237 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1315,6 +1315,9 @@ class AccountsController(TransactionBase): payment_schedule['discount_type'] = schedule.discount_type payment_schedule['discount'] = schedule.discount + if not schedule.invoice_portion: + payment_schedule['payment_amount'] = schedule.payment_amount + self.append("payment_schedule", payment_schedule) def set_due_date(self): From f1c3bcee1fdb050df88b1eb52eabb9b8a534f294 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 2 Feb 2022 17:09:34 +0530 Subject: [PATCH 012/112] fix: Deadlock on making reverse GL Entries --- erpnext/accounts/general_ledger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 55bc9673c1..8b01e7c2d8 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -325,7 +325,7 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, "voucher_type": voucher_type, "voucher_no": voucher_no, "is_cancelled": 0 - }) + }, for_update=True) if gl_entries: validate_accounting_period(gl_entries) From ffec865e002748178a9d5b0d16c3e84af4b966ae Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 2 Feb 2022 17:14:42 +0530 Subject: [PATCH 013/112] fix: Make a deep copy of GLE --- erpnext/accounts/general_ledger.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 8b01e7c2d8..d71526340f 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -333,23 +333,24 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, set_as_cancel(gl_entries[0]['voucher_type'], gl_entries[0]['voucher_no']) for entry in gl_entries: - entry['name'] = None - debit = entry.get('debit', 0) - credit = entry.get('credit', 0) + new_gle = copy.deepcopy(entry) + new_gle['name'] = None + debit = new_gle.get('debit', 0) + credit = new_gle.get('credit', 0) - debit_in_account_currency = entry.get('debit_in_account_currency', 0) - credit_in_account_currency = entry.get('credit_in_account_currency', 0) + debit_in_account_currency = new_gle.get('debit_in_account_currency', 0) + credit_in_account_currency = new_gle.get('credit_in_account_currency', 0) - entry['debit'] = credit - entry['credit'] = debit - entry['debit_in_account_currency'] = credit_in_account_currency - entry['credit_in_account_currency'] = debit_in_account_currency + new_gle['debit'] = credit + new_gle['credit'] = debit + new_gle['debit_in_account_currency'] = credit_in_account_currency + new_gle['credit_in_account_currency'] = debit_in_account_currency - entry['remarks'] = "On cancellation of " + entry['voucher_no'] - entry['is_cancelled'] = 1 + new_gle['remarks'] = "On cancellation of " + new_gle['voucher_no'] + new_gle['is_cancelled'] = 1 - if entry['debit'] or entry['credit']: - make_entry(entry, adv_adj, "Yes") + if new_gle['debit'] or new_gle['credit']: + make_entry(new_gle, adv_adj, "Yes") def check_freezing_date(posting_date, adv_adj=False): From ccf63124d62139c586a3d6737460a67a942956b1 Mon Sep 17 00:00:00 2001 From: Subin Tom Date: Wed, 2 Feb 2022 20:13:33 +0530 Subject: [PATCH 014/112] fix: Coupon code item pricing dynamic updation issue --- erpnext/public/js/controllers/transaction.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 3791741663..ab3e802051 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2288,7 +2288,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe () => this.frm.doc.ignore_pricing_rule=1, () => me.ignore_pricing_rule(), () => this.frm.doc.ignore_pricing_rule=0, - () => me.apply_pricing_rule() + () => me.apply_pricing_rule(), + () => this.frm.save() ]); } else { frappe.run_serially([ From ed2c6b6637960fe67846f933049d288078993b92 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 2 Feb 2022 22:35:36 +0530 Subject: [PATCH 015/112] fix: Incorrect provisional profit and loss in balance sheet --- erpnext/accounts/report/balance_sheet/balance_sheet.py | 6 +++--- erpnext/accounts/report/financial_statements.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index dc1f7aae42..f10a5eab10 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -120,11 +120,11 @@ def check_opening_balance(asset, liability, equity): opening_balance = 0 float_precision = cint(frappe.db.get_default("float_precision")) or 2 if asset: - opening_balance = flt(asset[0].get("opening_balance", 0), float_precision) + opening_balance = flt(asset[-1].get("opening_balance", 0), float_precision) if liability: - opening_balance -= flt(liability[0].get("opening_balance", 0), float_precision) + opening_balance -= flt(liability[-1].get("opening_balance", 0), float_precision) if equity: - opening_balance -= flt(equity[0].get("opening_balance", 0), float_precision) + opening_balance -= flt(equity[-1].get("opening_balance", 0), float_precision) opening_balance = flt(opening_balance, float_precision) if opening_balance: diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 03ae0aea13..db28cdfdd3 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -282,7 +282,8 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency total_row = { "account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)), "account": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)), - "currency": company_currency + "currency": company_currency, + "opening_balance": 0.0 } for row in out: @@ -294,6 +295,7 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency total_row.setdefault("total", 0.0) total_row["total"] += flt(row["total"]) + total_row["opening_balance"] += row["opening_balance"] row["total"] = "" if "total" in total_row: From 233e6449fc7fe1d09b1a034bb3491aca0b39028d Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 3 Feb 2022 12:13:43 +0530 Subject: [PATCH 016/112] fix: Zero rated exports in GSTR-3B report --- .../regional/doctype/gstr_3b_report/gstr_3b_report.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 d48cd67c38..cb79cf8286 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -295,6 +295,10 @@ class GSTR3BReport(Document): inter_state_supply_details = {} for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): + gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category') + place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory' + export_type = self.invoice_detail_map.get(inv, {}).get('export_type') + for rate, items in items_based_on_rate.items(): for item_code, taxable_value in self.invoice_items.get(inv).items(): if item_code in items: @@ -302,9 +306,8 @@ class GSTR3BReport(Document): self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value elif item_code in self.is_non_gst: self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value - elif rate == 0: + elif rate == 0 or (gst_category == 'Overseas' and export_type == 'Without Payment of Tax'): self.report_dict['sup_details']['osup_zero']['txval'] += taxable_value - #self.report_dict['sup_details']['osup_zero'][key] += tax_amount else: if inv in self.cgst_sgst_invoices: tax_rate = rate/2 @@ -315,9 +318,6 @@ class GSTR3BReport(Document): self.report_dict['sup_details']['osup_det']['iamt'] += (taxable_value * rate /100) self.report_dict['sup_details']['osup_det']['txval'] += taxable_value - gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category') - place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory' - if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \ self.gst_details.get("gst_state") != place_of_supply.split("-")[1]: inter_state_supply_details.setdefault((gst_category, place_of_supply), { From 6f7ae6290779181d1ad6db11f716a50f8c94072a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 4 Feb 2022 20:13:20 +0530 Subject: [PATCH 017/112] fix: Ignore linked invoices on Journal Entry cancel --- erpnext/accounts/doctype/journal_entry/journal_entry.js | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 617b376128..3cc28a3dc8 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -8,6 +8,7 @@ frappe.provide("erpnext.journal_entry"); frappe.ui.form.on("Journal Entry", { setup: function(frm) { frm.add_fetch("bank_account", "account", "account"); + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice']; }, refresh: function(frm) { From bd41a99c8a5c9a8b49174727748ce237a1a87797 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 4 Feb 2022 22:06:56 +0530 Subject: [PATCH 018/112] fix: Regenerate packed items on newly mapped doc - Cannot determine action on newly mapped DN that hasnt been inserted - Rows could have been deleted, updated, added, etc. before first save - In this case , reset packing table --- .../stock/doctype/packed_item/packed_item.py | 15 +++++---- .../doctype/packed_item/test_packed_item.py | 31 ++++++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index e3b5795f4c..07c2f1f0dd 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -27,8 +27,7 @@ def make_packing_list(doc): stale_packed_items_table = get_indexed_packed_items_table(doc) - if not doc.is_new(): - reset = reset_packing_list_if_deleted_items_exist(doc) + reset = reset_packing_list(doc) for item_row in doc.get("items"): if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}): @@ -64,20 +63,24 @@ def get_indexed_packed_items_table(doc): return indexed_table -def reset_packing_list_if_deleted_items_exist(doc): - doc_before_save = doc.get_doc_before_save() +def reset_packing_list(doc): + "Conditionally reset the table and return if it was reset or not." reset_table = False + doc_before_save = doc.get_doc_before_save() if doc_before_save: # reset table if: # 1. items were deleted # 2. if bundle item replaced by another item (same no. of items but different items) - # we maintain list to maintain repeated item rows as well + # we maintain list to track recurring item rows as well items_before_save = [item.item_code for item in doc_before_save.get("items")] items_after_save = [item.item_code for item in doc.get("items")] reset_table = items_before_save != items_after_save else: - reset_table = True # reset if via Update Items (cannot determine action) + # reset: if via Update Items OR + # if new mapped doc with packed items set (SO -> DN) + # (cannot determine action) + reset_table = True if reset_table: doc.set("packed_items", []) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index ed4eecde1d..5cbaa1ea66 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle +from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.tests.utils import ERPNextTestCase, change_settings @@ -22,7 +23,7 @@ class TestPackedItem(ERPNextTestCase): qty=2 ) - def test_sales_order_adding_bundle_item(self): + def test_adding_bundle_item(self): "Test impact on packed items if bundle item row is added." so = make_sales_order(item_code = "_Test Product Bundle X", qty=1, do_not_submit=True) @@ -32,7 +33,7 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1") self.assertEqual(so.packed_items[0].qty, 2) - def test_sales_order_updating_bundle_item(self): + def test_updating_bundle_item(self): "Test impact on packed items if bundle item row is updated." so = make_sales_order(item_code = "_Test Product Bundle X", qty=1, do_not_submit=True) @@ -49,7 +50,7 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(len(so.packed_items), 0) - def test_sales_order_recurring_bundle_item(self): + def test_recurring_bundle_item(self): "Test impact on packed items if same bundle item is added and removed." so_items = [] for qty in [2, 4, 6, 8]: @@ -91,7 +92,7 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(so.packed_items[3].qty, 12) @change_settings("Selling Settings", {"editable_bundle_item_rates": 1}) - def test_sales_order_bundle_item_cumulative_price(self): + def test_bundle_item_cumulative_price(self): "Test if Bundle Item rate is cumulative from packed items." so = make_sales_order(item_code = "_Test Product Bundle X", qty=2, do_not_submit=True) @@ -102,3 +103,25 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(so.items[0].rate, 350) self.assertEqual(so.items[0].amount, 700) + + def test_newly_mapped_doc_packed_items(self): + "Test impact on packed items in newly mapped DN from SO." + so_items = [] + for qty in [2, 4]: + so_items.append({ + "item_code": "_Test Product Bundle X", + "qty": qty, + "rate": 400, + "warehouse": "_Test Warehouse - _TC" + }) + + # create SO with recurring bundle item + so = make_sales_order(item_list=so_items) + + dn = make_delivery_note(so.name) + dn.items[1].qty = 3 # change second row qty for inserting doc + dn.save() + + self.assertEqual(len(dn.packed_items), 4) + self.assertEqual(dn.packed_items[2].qty, 6) + self.assertEqual(dn.packed_items[3].qty, 6) \ No newline at end of file From 65923bd856e5e6973de52344c77231d381d1bc6c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 4 Feb 2022 22:34:11 +0530 Subject: [PATCH 019/112] fix: enable Allow on Submit for 'Is Active' field in Salary Structure (#29630) --- .../payroll/doctype/salary_structure/salary_structure.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.json b/erpnext/payroll/doctype/salary_structure/salary_structure.json index 5dd1d701f0..8df995769d 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.json +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.json @@ -58,6 +58,7 @@ "width": "50%" }, { + "allow_on_submit": 1, "default": "Yes", "fieldname": "is_active", "fieldtype": "Select", @@ -232,10 +233,11 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2021-03-31 15:41:12.342380", + "modified": "2022-02-03 23:50:10.205676", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -271,5 +273,6 @@ ], "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file From 25c7f850b14f1f423631225725ad7d2e9647049f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sat, 5 Feb 2022 16:05:46 +0530 Subject: [PATCH 020/112] fix: earned leaves not allocated if assignment is created on month-end --- .../leave_policy_assignment.py | 21 +++++++++++++++--- erpnext/hr/utils.py | 22 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 355370f3a4..41a9558deb 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,7 +8,7 @@ from math import ceil import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate +from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate class LeavePolicyAssignment(Document): @@ -108,8 +108,8 @@ class LeavePolicyAssignment(Document): def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): from erpnext.hr.utils import get_monthly_earned_leave - current_month = get_datetime().month - current_year = get_datetime().year + current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month + current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date") if getdate(date_of_joining) > getdate(from_date): @@ -119,10 +119,14 @@ class LeavePolicyAssignment(Document): from_date_year = get_datetime(from_date).year months_passed = 0 + if current_year == from_date_year and current_month > from_date_month: months_passed = current_month - from_date_month + months_passed = add_current_month_if_applicable(months_passed) + elif current_year > from_date_year: months_passed = (12 - from_date_month) + current_month + months_passed = add_current_month_if_applicable(months_passed) if months_passed > 0: monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, @@ -134,6 +138,17 @@ class LeavePolicyAssignment(Document): return new_leaves_allocated +def add_current_month_if_applicable(months_passed): + date = getdate(frappe.flags.current_date) or getdate() + last_day_of_month = get_last_day(date) + + # if its the last day of the month, then that month should also be considered + if last_day_of_month == date: + months_passed += 1 + + return months_passed + + @frappe.whitelist() def create_assignment_for_multiple_employees(employees, data): diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 0febce1610..2006ef3a53 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -277,9 +277,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type new_allocation = e_leave_type.max_leaves_allowed if new_allocation != allocation.total_leaves_allocated: - allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) today_date = today() - create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + + if not is_earned_leave_already_allocated(allocation, annual_allocation): + allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) + create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + def get_monthly_earned_leave(annual_leaves, frequency, rounding): earned_leaves = 0.0 @@ -297,6 +300,21 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding): return earned_leaves +def is_earned_leave_already_allocated(allocation, annual_allocation): + from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import get_leave_type_details + + leave_type_details = get_leave_type_details() + date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") + + assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) + leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, + annual_allocation, leave_type_details, date_of_joining) + + if allocation.total_leaves_allocated >= leaves_for_passed_months: + return True + return False + + def get_leave_allocations(date, leave_type): return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy from `tabLeave Allocation` From 63ee4f1b64b0110d6d97f4114605db2732dcb224 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sat, 5 Feb 2022 16:05:54 +0530 Subject: [PATCH 021/112] test: earned leave allocation for passed months and allocation on month-end --- .../test_leave_policy_assignment.py | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 3b7f8ec822..3455baeb08 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, get_first_day, getdate +from frappe.utils import add_months, get_first_day, get_last_day, getdate from erpnext.hr.doctype.leave_application.test_leave_application import ( get_employee, @@ -125,6 +125,69 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 0) + def test_earned_leave_allocation_for_passed_months(self): + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -1))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # Case 1: assignment created one month after the leave period, should allocate 1 leave + frappe.flags.current_date = get_first_day(getdate()) + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 1) + + def test_earned_leave_allocation_for_passed_months_on_month_end(self): + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -2))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # Case 2: assignment created on the last day of the leave period's latter month + # should allocate 1 leave for current month even though the month has not ended + # since the daily job might have already executed + frappe.flags.current_date = get_last_day(getdate()) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + def tearDown(self): frappe.db.rollback() @@ -137,14 +200,14 @@ def create_earned_leave_type(leave_type): doctype="Leave Type", is_earned_leave=1, earned_leave_frequency="Monthly", - rounding=0.5, - max_leaves_allowed=6 + rounding=0.5 )).insert() -def create_leave_period(name): +def create_leave_period(name, start_date=None): frappe.delete_doc_if_exists("Leave Period", name, force=1) - start_date = get_first_day(getdate()) + if not start_date: + start_date = get_first_day(getdate()) return frappe.get_doc(dict( name=name, From a52ba0a5447df1998b7900230ce1cdb4a0a3dace Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sat, 5 Feb 2022 16:40:55 +0530 Subject: [PATCH 022/112] fix: linter --- erpnext/hr/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 2006ef3a53..ea69da7610 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -301,7 +301,9 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding): def is_earned_leave_already_allocated(allocation, annual_allocation): - from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import get_leave_type_details + from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( + get_leave_type_details, + ) leave_type_details = get_leave_type_details() date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") From 8858c703a894ec35f5e44a91b82a7cc1f40ba2b4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 5 Feb 2022 18:29:47 +0530 Subject: [PATCH 023/112] fix: ignore cancelled svd while updating GLE for PR This happens because LCV cancels and reposts entries so unless filtered by non-cancelled entries you can randomly get old values. --- .../accounts/doctype/purchase_invoice/purchase_invoice.py | 7 +++++-- erpnext/stock/doctype/purchase_receipt/purchase_receipt.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 279557adc7..76d9cc7b49 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -537,8 +537,11 @@ class PurchaseInvoice(BuyingController): voucher_wise_stock_value = {} if self.update_stock: - for d in frappe.get_all('Stock Ledger Entry', - fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}): + stock_ledger_entries = frappe.get_all("Stock Ledger Entry", + fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], + filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0} + ) + for d in stock_ledger_entries: voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference) valuation_tax_accounts = [d.account_head for d in self.get("taxes") diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 1257057ea3..ffdf8c420c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -286,7 +286,7 @@ class PurchaseReceipt(BuyingController): if warehouse_account.get(d.warehouse): stock_value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Purchase Receipt", "voucher_no": self.name, - "voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference") + "voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference") if not stock_value_diff: continue From 69c65afd729d87115ee469f2bf8240af43d55e65 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 5 Feb 2022 20:15:39 +0530 Subject: [PATCH 024/112] test: regression test for LCV GL entries --- .../test_landed_cost_voucher.py | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 9204842b8f..df8cadd7f8 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -4,10 +4,11 @@ import frappe -from frappe.utils import flt +from frappe.utils import add_to_date, flt, now from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.accounts.utils import update_gl_entries_after from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_gl_entries, @@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase): "voucher_type": pr.doctype, "voucher_no": pr.name, "item_code": "_Test Item", - "warehouse": "Stores - TCP1" + "warehouse": "Stores - TCP1", + "is_cancelled": 0, }, fieldname=["qty_after_transaction", "stock_value"], as_dict=1) @@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase): "voucher_type": pr.doctype, "voucher_no": pr.name, "item_code": "_Test Item", - "warehouse": "Stores - TCP1" + "warehouse": "Stores - TCP1", + "is_cancelled": 0, }, fieldname=["qty_after_transaction", "stock_value"], as_dict=1) self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction) - self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0) + # assert after submit + self.assertPurchaseReceiptLCVGLEntries(pr) + + # Mess up cancelled SLE modified timestamp to check + # if they aren't effective in any business logic. + frappe.db.set_value("Stock Ledger Entry", + { + "is_cancelled": 1, + "voucher_type": pr.doctype, + "voucher_no": pr.name + }, + "is_cancelled", 1, + modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True) + ) + + items, warehouses = pr.get_items_and_warehouses() + update_gl_entries_after(pr.posting_date, pr.posting_time, + warehouses, items, company=pr.company) + + # reassert after reposting + self.assertPurchaseReceiptLCVGLEntries(pr) + + + def assertPurchaseReceiptLCVGLEntries(self, pr): + gl_entries = get_gl_entries("Purchase Receipt", pr.name) self.assertTrue(gl_entries) @@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase): for gle in gl_entries: if not gle.get('is_cancelled'): - self.assertEqual(expected_values[gle.account][0], gle.debit) - self.assertEqual(expected_values[gle.account][1], gle.credit) + self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}") + self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}") def test_landed_cost_voucher_against_purchase_invoice(self): From c88c368880a134b12ad82d7674cb5e12d5a858ba Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 5 Feb 2022 22:54:18 +0530 Subject: [PATCH 025/112] fix: dont ignore items that dont have SVD When items go from negative to positive stock value diff can be zero but item might have taxes / need divisional loss adjustment. --- erpnext/stock/doctype/purchase_receipt/purchase_receipt.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index ffdf8c420c..33e40c85f1 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -288,9 +288,6 @@ class PurchaseReceipt(BuyingController): {"voucher_type": "Purchase Receipt", "voucher_no": self.name, "voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference") - if not stock_value_diff: - continue - warehouse_account_name = warehouse_account[d.warehouse]["account"] warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"] supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get("account") From d1f57538855eaa8c6598a553a8b776b3ab511171 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 5 Feb 2022 23:35:28 +0530 Subject: [PATCH 026/112] test: check when PR moves stock from neg to pos --- .../purchase_receipt/test_purchase_receipt.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index b87d9205e0..5ab7929a2a 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4,6 +4,7 @@ import json import unittest +from collections import defaultdict import frappe from frappe.utils import add_days, cint, cstr, flt, today @@ -16,7 +17,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction -from erpnext.tests.utils import ERPNextTestCase +from erpnext.tests.utils import ERPNextTestCase, change_settings class TestPurchaseReceipt(ERPNextTestCase): @@ -1387,6 +1388,36 @@ class TestPurchaseReceipt(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) + @change_settings("Stock Settings", {"allow_negative_stock": 1}) + def test_neg_to_positive(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = "_TestNegToPosItem" + warehouse = "Stores - TCP1" + company = "_Test Company with perpetual inventory" + account = "Stock Received But Not Billed - TCP1" + + make_item(item_code) + se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0) + se.items[0].allow_zero_valuation_rate = 1 + se.save() + se.submit() + + pr = make_purchase_receipt( + qty=50, + rate=1, + item_code=item_code, + warehouse=warehouse, + get_taxes_and_charges=True, + company=company, + ) + gles = get_gl_entries(pr.doctype, pr.name) + + for gle in gles: + if gle.account == account: + self.assertEqual(gle.credit, 50) + + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s From d6796924728e998c3331e3af8c58556b0bbd7db1 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sun, 6 Feb 2022 11:35:23 +0530 Subject: [PATCH 027/112] fix: Billing status for zero amount ref doc --- erpnext/controllers/status_updater.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 76a7cdab51..affde4aa8a 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -400,6 +400,16 @@ class StatusUpdater(Document): ref_doc = frappe.get_doc(ref_dt, ref_dn) ref_doc.db_set("per_billed", per_billed) + + # set billling status + if hasattr(ref_doc, 'billing_status'): + if ref_doc.per_billed < 0.001: + ref_doc.db_set("billing_status", "Not Billed") + elif ref_doc.per_billed > 99.999999: + ref_doc.db_set("billing_status", "Fully Billed") + else: + ref_doc.db_set("billing_status", "Partly Billed") + ref_doc.set_status(update=True) def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"): From c5726fd3da410874c4532887d2c625c54bf6de6a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sun, 6 Feb 2022 11:35:40 +0530 Subject: [PATCH 028/112] fix: Add test case --- .../doctype/sales_order/test_sales_order.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 42bc0b70f8..acf048e116 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1375,6 +1375,30 @@ class TestSalesOrder(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) + def test_zero_amount_sales_order_billing_status(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + so = make_sales_order(uom="Nos", do_not_save=1) + so.items[0].rate = 0 + so.save() + so.submit() + + self.assertEqual(so.net_total, 0) + self.assertEqual(so.billing_status, 'Not Billed') + + si = create_sales_invoice(qty=10, do_not_save=1) + si.price_list = '_Test Price List' + si.items[0].rate = 0 + si.items[0].price_list_rate = 0 + si.items[0].sales_order = so.name + si.items[0].so_detail = so.items[0].name + si.save() + si.submit() + + self.assertEqual(si.net_total, 0) + so.load_from_db() + self.assertEqual(so.billing_status, 'Fully Billed') + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") accounts_settings.automatically_fetch_payment_terms = enable From d0043bdbace8cac51527c9998435aa5c4f438b25 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 6 Feb 2022 16:55:24 +0530 Subject: [PATCH 029/112] fix: missing key in loan --- .../loan_management/doctype/loan_repayment/loan_repayment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 7e997e87c3..204fce797d 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -125,7 +125,7 @@ class LoanRepayment(AccountsController): def update_paid_amount(self): loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid', - 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable', + 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable', 'written_off_amount'], as_dict=1) loan.update({ From 6459ceaea14cc8422de83dbca060e1c69b0e1d13 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 18:47:21 +0530 Subject: [PATCH 030/112] fix: dont show cancelled PO items in plan report --- .../production_planning_report.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py index 8368db6374..e1e7225e05 100644 --- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py +++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py @@ -172,10 +172,15 @@ class ProductionPlanReport(object): self.purchase_details = {} - for d in frappe.get_all("Purchase Order Item", + purchased_items = frappe.get_all("Purchase Order Item", fields=["item_code", "min(schedule_date) as arrival_date", "qty as arrival_qty", "warehouse"], - filters = {"item_code": ("in", self.item_codes), "warehouse": ("in", self.warehouses)}, - group_by = "item_code, warehouse"): + filters={ + "item_code": ("in", self.item_codes), + "warehouse": ("in", self.warehouses), + "docstatus": 1, + }, + group_by = "item_code, warehouse") + for d in purchased_items: key = (d.item_code, d.warehouse) if key not in self.purchase_details: self.purchase_details.setdefault(key, d) From b855030e7c1b51b07010194ef023c6a869af7ed1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 15 Jan 2022 12:36:56 +0530 Subject: [PATCH 031/112] refactor: valuation class to allow extending --- erpnext/stock/stock_ledger.py | 2 +- erpnext/stock/valuation.py | 118 ++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0a7ab4009c..9bd7353fed 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -723,7 +723,7 @@ class update_entries_after(object): stock_qty, stock_value = fifo_queue.get_total_stock_and_value() - self.wh_data.stock_queue = fifo_queue.get_state() + self.wh_data.stock_queue = fifo_queue.state self.wh_data.stock_value = stock_value if stock_qty: self.wh_data.valuation_rate = stock_value / stock_qty diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py index 45c5083099..f056439bcc 100644 --- a/erpnext/stock/valuation.py +++ b/erpnext/stock/valuation.py @@ -1,15 +1,54 @@ +from abc import ABC, abstractmethod, abstractproperty from typing import Callable, List, NewType, Optional, Tuple from frappe.utils import flt -FifoBin = NewType("FifoBin", List[float]) +StockBin = NewType("FifoBin", List[float]) # Indexes of values inside FIFO bin 2-tuple QTY = 0 RATE = 1 -class FIFOValuation: +class BinWiseValuation(ABC): + + @abstractmethod + def add_stock(self, qty: float, rate: float) -> None: + pass + + @abstractmethod + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + pass + + @abstractproperty + def state(self) -> List[StockBin]: + pass + + def get_total_stock_and_value(self) -> Tuple[float, float]: + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in self.state: + total_qty += flt(qty) + total_value += flt(qty) * flt(rate) + + return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) + + def __repr__(self): + return str(self.state) + + def __iter__(self): + return iter(self.state) + + def __eq__(self, other): + if isinstance(other, list): + return self.state == other + return type(self) == type(other) and self.state == other.state + + +class FIFOValuation(BinWiseValuation): """Valuation method where a queue of all the incoming stock is maintained. New stock is added at end of the queue. @@ -24,34 +63,14 @@ class FIFOValuation: # ref: https://docs.python.org/3/reference/datamodel.html#slots __slots__ = ["queue",] - def __init__(self, state: Optional[List[FifoBin]]): - self.queue: List[FifoBin] = state if state is not None else [] + def __init__(self, state: Optional[List[StockBin]]): + self.queue: List[StockBin] = state if state is not None else [] - def __repr__(self): - return str(self.queue) - - def __iter__(self): - return iter(self.queue) - - def __eq__(self, other): - if isinstance(other, list): - return self.queue == other - return self.queue == other.queue - - def get_state(self) -> List[FifoBin]: + @property + def state(self) -> List[StockBin]: """Get current state of queue.""" return self.queue - def get_total_stock_and_value(self) -> Tuple[float, float]: - total_qty = 0.0 - total_value = 0.0 - - for qty, rate in self.queue: - total_qty += flt(qty) - total_value += flt(qty) * flt(rate) - - return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) - def add_stock(self, qty: float, rate: float) -> None: """Update fifo queue with new stock. @@ -78,7 +97,7 @@ class FIFOValuation: def remove_stock( self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None - ) -> List[FifoBin]: + ) -> List[StockBin]: """Remove stock from the queue and return popped bins. args: @@ -136,6 +155,51 @@ class FIFOValuation: return consumed_bins +class LIFOValuation(BinWiseValuation): + """Valuation method where a *stack* of all the incoming stock is maintained. + + New stock is added at top of the stack. + Qty consumption happens on Last In First Out basis. + + Stack is implemented using "bins" of [qty, rate]. + + ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting + """ + + # specifying the attributes to save resources + # ref: https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = ["queue",] + + def __init__(self, state: Optional[List[StockBin]]): + self.stack: List[StockBin] = state if state is not None else [] + + @property + def state(self) -> List[StockBin]: + """Get current state of stack.""" + return self.stack + + def add_stock(self, qty: float, rate: float) -> None: + """Update lifo stack with new stock. + + args: + qty: new quantity to add + rate: incoming rate of new quantity""" + pass + + + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + """Remove stock from the stack and return popped bins. + + args: + qty: quantity to remove + rate: outgoing rate + rate_generator: function to be called if stack is not found and rate is required. + """ + pass + + def _round_off_if_near_zero(number: float, precision: int = 7) -> float: """Rounds off the number to zero only if number is close to zero for decimal specified in precision. Precision defaults to 7. From 9c49d2d3aa6b5ad0a7a090ce10c098278c792faa Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 15 Jan 2022 12:52:10 +0530 Subject: [PATCH 032/112] feat: LIFOValuation class for handling LIFO --- erpnext/stock/tests/test_valuation.py | 124 +++++++++++++++++++++++++- erpnext/stock/valuation.py | 62 +++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index 85788bac7f..623040e009 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -3,14 +3,14 @@ import unittest from hypothesis import given from hypothesis import strategies as st -from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero qty_gen = st.floats(min_value=-1e6, max_value=1e6) value_gen = st.floats(min_value=1, max_value=1e6) stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10) -class TestFifoValuation(unittest.TestCase): +class TestFIFOValuation(unittest.TestCase): def setUp(self): self.queue = FIFOValuation([]) @@ -164,3 +164,123 @@ class TestFifoValuation(unittest.TestCase): total_value -= sum(q * r for q, r in consumed) self.assertTotalQty(total_qty) self.assertTotalValue(total_value) + + +class TestLIFOValuation(unittest.TestCase): + + def setUp(self): + self.stack = LIFOValuation([]) + + def tearDown(self): + qty, value = self.stack.get_total_stock_and_value() + self.assertTotalQty(qty) + self.assertTotalValue(value) + + def assertTotalQty(self, qty): + self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4) + + def assertTotalValue(self, value): + self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2) + + def test_simple_addition(self): + self.stack.add_stock(1, 10) + self.assertTotalQty(1) + + def test_merge_new_stock(self): + self.stack.add_stock(1, 10) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[2, 10]]) + + def test_simple_removal(self): + self.stack.add_stock(1, 10) + self.stack.remove_stock(1) + self.assertTotalQty(0) + + def test_adding_negative_stock_keeps_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[-4, 100]]) + + def test_adding_negative_stock_updates_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(6, 10) + self.assertEqual(self.stack, [[1, 10]]) + + def test_rounding_off(self): + self.stack.add_stock(1.0, 1.0) + self.stack.remove_stock(1.0 - 1e-9) + self.assertTotalQty(0) + + def test_lifo_consumption(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(15) + self.assertEqual(consumed, [[10, 20], [5, 10]]) + self.assertTotalQty(5) + + def test_lifo_consumption_going_negative(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(25) + self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]]) + self.assertTotalQty(-5) + + def test_lifo_consumption_multiple(self): + self.stack.add_stock(1, 1) + self.stack.add_stock(2, 2) + consumed = self.stack.remove_stock(1) + self.assertEqual(consumed, [[1, 2]]) + + self.stack.add_stock(3, 3) + consumed = self.stack.remove_stock(4) + self.assertEqual(consumed, [[3, 3], [1, 2]]) + + self.stack.add_stock(4, 4) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[4, 4], [1, 1]]) + + self.stack.add_stock(5, 5) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[5, 5]]) + + + @given(stock_queue_generator) + def test_lifo_qty_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0 + + for qty, rate in stock_stack: + if qty == 0: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + self.assertTotalQty(total_qty) + + @given(stock_queue_generator) + def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in stock_stack: + # don't allow negative stock + if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + total_value += qty * rate + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + total_value -= sum(q * r for q, r in consumed) + self.assertTotalQty(total_qty) + self.assertTotalValue(total_value) diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py index f056439bcc..ee9477ed74 100644 --- a/erpnext/stock/valuation.py +++ b/erpnext/stock/valuation.py @@ -3,7 +3,7 @@ from typing import Callable, List, NewType, Optional, Tuple from frappe.utils import flt -StockBin = NewType("FifoBin", List[float]) +StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...] # Indexes of values inside FIFO bin 2-tuple QTY = 0 @@ -164,11 +164,12 @@ class LIFOValuation(BinWiseValuation): Stack is implemented using "bins" of [qty, rate]. ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting + Implementation detail: appends and pops both at end of list. """ # specifying the attributes to save resources # ref: https://docs.python.org/3/reference/datamodel.html#slots - __slots__ = ["queue",] + __slots__ = ["stack",] def __init__(self, state: Optional[List[StockBin]]): self.stack: List[StockBin] = state if state is not None else [] @@ -183,8 +184,26 @@ class LIFOValuation(BinWiseValuation): args: qty: new quantity to add - rate: incoming rate of new quantity""" - pass + rate: incoming rate of new quantity. + + Behaviour of this is same as FIFO valuation. + """ + if not len(self.stack): + self.stack.append([0, 0]) + + # last row has the same rate, merge new bin. + if self.stack[-1][RATE] == rate: + self.stack[-1][QTY] += qty + else: + # Item has a positive balance qty, add new entry + if self.stack[-1][QTY] > 0: + self.stack.append([qty, rate]) + else: # negative balance qty + qty = self.stack[-1][QTY] + qty + if qty > 0: # new balance qty is positive + self.stack[-1] = [qty, rate] + else: # new balance qty is still negative, maintain same rate + self.stack[-1][QTY] = qty def remove_stock( @@ -194,10 +213,41 @@ class LIFOValuation(BinWiseValuation): args: qty: quantity to remove - rate: outgoing rate + rate: outgoing rate - ignored. Kept for backwards compatibility. rate_generator: function to be called if stack is not found and rate is required. """ - pass + if not rate_generator: + rate_generator = lambda : 0.0 # noqa + + consumed_bins = [] + while qty: + if not len(self.stack): + # rely on rate generator. + self.stack.append([0, rate_generator()]) + + # start at the end. + index = -1 + + stock_bin = self.stack[index] + if qty >= stock_bin[QTY]: + # consume current bin + qty = _round_off_if_near_zero(qty - stock_bin[QTY]) + to_consume = self.stack.pop(index) + consumed_bins.append(list(to_consume)) + + if not self.stack and qty: + # stock finished, qty still remains to be withdrawn + # negative stock, keep in as a negative bin + self.stack.append([-qty, outgoing_rate or stock_bin[RATE]]) + consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]]) + break + else: + # qty found in current bin consume it and exit + stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty) + consumed_bins.append([qty, stock_bin[RATE]]) + qty = 0 + + return consumed_bins def _round_off_if_near_zero(number: float, precision: int = 7) -> float: From 97e18a1cd052bcc7155563ffcd2e041e59a1a56a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 15 Jan 2022 17:42:25 +0530 Subject: [PATCH 033/112] feat: allow selecting LIFO valuation --- erpnext/stock/doctype/item/item.json | 4 ++-- .../stock_settings/stock_settings.json | 5 +++-- erpnext/stock/stock_ledger.py | 20 +++++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index e71cdb37cf..b05f58a982 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -346,7 +346,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Valuation Method", - "options": "\nFIFO\nMoving Average" + "options": "\nFIFO\nMoving Average\nLIFO" }, { "depends_on": "is_stock_item", @@ -987,4 +987,4 @@ "states": [], "title_field": "item_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 33d9a6ce41..a95affb5cc 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -92,7 +92,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Default Valuation Method", - "options": "FIFO\nMoving Average" + "options": "FIFO\nMoving Average\nLIFO" }, { "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", @@ -305,7 +305,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-11-06 19:40:02.183592", + "modified": "2022-01-15 17:42:53.174865", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", @@ -324,5 +324,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9bd7353fed..e9279a456e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -16,7 +16,7 @@ from erpnext.stock.utils import ( get_or_make_bin, get_valuation_method, ) -from erpnext.stock.valuation import FIFOValuation +from erpnext.stock.valuation import FIFOValuation, LIFOValuation class NegativeStockError(frappe.ValidationError): pass @@ -461,7 +461,7 @@ class update_entries_after(object): self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: - self.update_fifo_values(sle) + self.update_fifo_lifo_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) # rounding as per precision @@ -701,14 +701,18 @@ class update_entries_after(object): sle.voucher_type, sle.voucher_no, self.allow_zero_rate, currency=erpnext.get_company_currency(sle.company), company=sle.company) - def update_fifo_values(self, sle): + def update_fifo_lifo_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) outgoing_rate = flt(sle.outgoing_rate) - fifo_queue = FIFOValuation(self.wh_data.stock_queue) + if self.valuation_method == "LIFO": + stock_queue = LIFOValuation(self.wh_data.stock_queue) + else: + stock_queue = FIFOValuation(self.wh_data.stock_queue) + if actual_qty > 0: - fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate) + stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) else: def rate_generator() -> float: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) @@ -719,11 +723,11 @@ class update_entries_after(object): else: return 0.0 - fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) + stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) - stock_qty, stock_value = fifo_queue.get_total_stock_and_value() + stock_qty, stock_value = stock_queue.get_total_stock_and_value() - self.wh_data.stock_queue = fifo_queue.state + self.wh_data.stock_queue = stock_queue.state self.wh_data.stock_value = stock_value if stock_qty: self.wh_data.valuation_rate = stock_value / stock_qty From 61c5ad44d3fe282e453d77df8acd1fbf9642c44a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 15 Jan 2022 18:06:50 +0530 Subject: [PATCH 034/112] refactor: get incoming fifo/lifo rate functions Re-use same logic for computing incoming rate. --- erpnext/stock/utils.py | 45 ++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 7c63c17ad0..c75c737fc5 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime import erpnext +from erpnext.stock.valuation import FIFOValuation, LIFOValuation class InvalidWarehouseCompany(frappe.ValidationError): pass @@ -228,10 +229,10 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): else: valuation_method = get_valuation_method(args.get("item_code")) previous_sle = get_previous_sle(args) - if valuation_method == 'FIFO': + if valuation_method in ('FIFO', 'LIFO'): if previous_sle: previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]') - in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0 + in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0 elif valuation_method == 'Moving Average': in_rate = previous_sle.get('valuation_rate') or 0 @@ -261,29 +262,25 @@ def get_valuation_method(item_code): def get_fifo_rate(previous_stock_queue, qty): """get FIFO (average) Rate from Queue""" - if flt(qty) >= 0: - total = sum(f[0] for f in previous_stock_queue) - return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0 - else: - available_qty_for_outgoing, outgoing_cost = 0, 0 - qty_to_pop = abs(flt(qty)) - while qty_to_pop and previous_stock_queue: - batch = previous_stock_queue[0] - if 0 < batch[0] <= qty_to_pop: - # if batch qty > 0 - # not enough or exactly same qty in current batch, clear batch - available_qty_for_outgoing += flt(batch[0]) - outgoing_cost += flt(batch[0]) * flt(batch[1]) - qty_to_pop -= batch[0] - previous_stock_queue.pop(0) - else: - # all from current batch - available_qty_for_outgoing += flt(qty_to_pop) - outgoing_cost += flt(qty_to_pop) * flt(batch[1]) - batch[0] -= qty_to_pop - qty_to_pop = 0 + return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO") - return outgoing_cost / available_qty_for_outgoing +def get_lifo_rate(previous_stock_queue, qty): + """get LIFO (average) Rate from Queue""" + return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO") + + +def _get_fifo_lifo_rate(previous_stock_queue, qty, method): + ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation + + stock_queue = ValuationKlass(previous_stock_queue) + if flt(qty) >= 0: + total_qty, total_value = stock_queue.get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 + else: + popped_bins = stock_queue.remove_stock(abs(flt(qty))) + + total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 def get_valid_serial_nos(sr_nos, qty=0, item_code=''): """split serial nos, validate and return list of valid serial nos""" From 3e5f940686bf398a56b9789198376c8d789ab25d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 15 Jan 2022 18:49:46 +0530 Subject: [PATCH 035/112] test: e2e test for LIFO valuation --- .../stock_ledger_invariant_check.py | 2 +- erpnext/stock/tests/test_valuation.py | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 48753b0edd..cb35bf75d1 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -167,7 +167,7 @@ def get_columns(): { "fieldname": "stock_queue", "fieldtype": "Data", - "label": "FIFO Queue", + "label": "FIFO/LIFO Queue", }, { diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index 623040e009..648d4406ca 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -1,9 +1,14 @@ +import json import unittest +import frappe from hypothesis import given from hypothesis import strategies as st +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero +from erpnext.tests.utils import ERPNextTestCase qty_gen = st.floats(min_value=-1e6, max_value=1e6) value_gen = st.floats(min_value=1, max_value=1e6) @@ -284,3 +289,64 @@ class TestLIFOValuation(unittest.TestCase): total_value -= sum(q * r for q, r in consumed) self.assertTotalQty(total_qty) self.assertTotalValue(total_value) + +class TestLIFOValuationSLE(ERPNextTestCase): + ITEM_CODE = "_Test LIFO item" + WAREHOUSE = "_Test Warehouse - _TC" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"}) + + def _make_stock_entry(self, qty, rate=None): + kwargs = { + "item_code": self.ITEM_CODE, + "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE, + "rate": rate, + "qty": abs(qty), + } + return make_stock_entry(**kwargs) + + def assertStockQueue(self, se, expected_queue): + sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}) + sle = frappe.get_doc("Stock Ledger Entry", sle_name) + + stock_queue = json.loads(sle.stock_queue) + + total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value() + self.assertEqual(sle.qty_after_transaction, total_qty) + self.assertEqual(sle.stock_value, total_value) + + if total_qty > 0: + self.assertEqual(stock_queue, expected_queue) + + + def test_lifo_values(self): + + in1 = self._make_stock_entry(1, 1) + self.assertStockQueue(in1, [[1, 1]]) + + in2 = self._make_stock_entry(2, 2) + self.assertStockQueue(in2, [[1, 1], [2, 2]]) + + out1 = self._make_stock_entry(-1) + self.assertStockQueue(out1, [[1, 1], [1, 2]]) + + in3 = self._make_stock_entry(3, 3) + self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]]) + + out2 = self._make_stock_entry(-4) + self.assertStockQueue(out2, [[1, 1]]) + + in4 = self._make_stock_entry(4, 4) + self.assertStockQueue(in4, [[1, 1], [4,4]]) + + out3 = self._make_stock_entry(-5) + self.assertStockQueue(out3, []) + + in5 = self._make_stock_entry(5, 5) + self.assertStockQueue(in5, [[5, 5]]) + + out5 = self._make_stock_entry(-5) + self.assertStockQueue(out5, []) From f089d396755d0671006d41e7d69296d02ea2a3a4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Feb 2022 12:51:21 +0530 Subject: [PATCH 036/112] refactor: better method name Co-authored-by: gavin --- erpnext/stock/stock_ledger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index e9279a456e..41c4002e3f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -461,7 +461,7 @@ class update_entries_after(object): self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: - self.update_fifo_lifo_values(sle) + self.update_queue_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) # rounding as per precision @@ -701,7 +701,7 @@ class update_entries_after(object): sle.voucher_type, sle.voucher_no, self.allow_zero_rate, currency=erpnext.get_company_currency(sle.company), company=sle.company) - def update_fifo_lifo_values(self, sle): + def update_queue_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) outgoing_rate = flt(sle.outgoing_rate) From e25544f94e20f675befd71e58df8156649cbf1f0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 6 Feb 2022 20:30:46 +0530 Subject: [PATCH 037/112] fix(test): add ignore duplicates flag to allocation function --- .../doctype/leave_application/test_leave_application.py | 4 ++-- erpnext/hr/utils.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 6b85927d3e..6d27f4abef 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -546,7 +546,7 @@ class TestLeaveApplication(unittest.TestCase): from erpnext.hr.utils import allocate_earned_leaves i = 0 while(i<14): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) @@ -554,7 +554,7 @@ class TestLeaveApplication(unittest.TestCase): frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0) i = 0 while(i<6): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index ea69da7610..2a07e56b1d 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -237,7 +237,7 @@ def generate_leave_encashment(): create_leave_encashment(leave_allocation=leave_allocation) -def allocate_earned_leaves(): +def allocate_earned_leaves(ignore_duplicates=False): '''Allocate earned leaves to Employees''' e_leave_types = get_earned_leaves() today = getdate() @@ -265,9 +265,9 @@ def allocate_earned_leaves(): from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): - update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) + update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates) -def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type): +def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False): earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) allocation = frappe.get_doc('Leave Allocation', allocation.name) @@ -279,7 +279,7 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type if new_allocation != allocation.total_leaves_allocated: today_date = today() - if not is_earned_leave_already_allocated(allocation, annual_allocation): + if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation): allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) From 36f4fb05850614589c72818be21fb75761ea66cf Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 4 Feb 2022 12:01:07 +0530 Subject: [PATCH 038/112] fix: Incorrect tax template in Sales Invocie via data import (cherry picked from commit 20f321a88980d231f52702e3d32e122022316152) --- erpnext/controllers/selling_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 75fcaee383..31b2209399 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -74,7 +74,8 @@ class SellingController(StockController): doctype=self.doctype, company=self.company, posting_date=self.get('posting_date'), fetch_payment_terms_template=fetch_payment_terms_template, - party_address=self.customer_address, shipping_address=self.shipping_address_name) + party_address=self.customer_address, shipping_address=self.shipping_address_name, + company_address=self.get('company_address')) if not self.meta.get_field("sales_team"): party_details.pop("sales_team") self.update_if_missing(party_details) From ff57450e770609919d5ace117052b9e45c39bbc0 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sun, 6 Feb 2022 22:56:12 +0530 Subject: [PATCH 039/112] fix: Replace ORM with query builder --- erpnext/accounts/general_ledger.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index d71526340f..d24d56b4bb 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -319,13 +319,18 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, """ if not gl_entries: - gl_entries = frappe.get_all("GL Entry", - fields = ["*"], - filters = { - "voucher_type": voucher_type, - "voucher_no": voucher_no, - "is_cancelled": 0 - }, for_update=True) + gl_entry = frappe.qb.DocType("GL Entry") + gl_entries = (frappe.qb.from_( + gl_entry + ).select( + '*' + ).where( + gl_entry.voucher_type == voucher_type + ).where( + gl_entry.voucher_no == voucher_no + ).where( + gl_entry.is_cancelled == 0 + ).for_update()).run(as_dict=1) if gl_entries: validate_accounting_period(gl_entries) From 104a55aff1092dadc1f76803d394bda7f86b3b84 Mon Sep 17 00:00:00 2001 From: Umair Sayed Date: Mon, 7 Feb 2022 10:42:55 +0530 Subject: [PATCH 040/112] feat: Tab views in Stocks and Accounts Settings (#29638) * tab views in Stocks and Accounts Settings. Clean-up in Selling and Buying Settings. * chore: undo changes to creation timestamp happened because of a bug in fw Co-authored-by: Umair Sayed --- .../accounts_settings/accounts_settings.json | 129 ++++++++++++++---- .../buying_settings/buying_settings.json | 32 ++++- .../selling_settings/selling_settings.json | 5 +- .../stock_settings/stock_settings.json | 72 ++++++++-- 4 files changed, 186 insertions(+), 52 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 55ea571ebf..9a35a247dd 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -7,35 +7,30 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "accounts_transactions_settings_section", - "over_billing_allowance", - "role_allowed_to_over_bill", - "credit_controller", - "make_payment_via_journal_entry", - "column_break_11", - "check_supplier_invoice_uniqueness", + "invoice_and_billing_tab", + "enable_features_section", "unlink_payment_on_cancellation_of_invoice", - "automatically_fetch_payment_terms", - "delete_linked_ledger_entries", - "book_asset_depreciation_entry_automatically", "unlink_advance_payment_on_cancelation_of_order", + "column_break_13", + "delete_linked_ledger_entries", + "invoicing_features_section", + "check_supplier_invoice_uniqueness", + "automatically_fetch_payment_terms", + "column_break_17", "enable_common_party_accounting", - "post_change_gl_entries", "enable_discount_accounting", - "tax_settings_section", - "determine_address_tax_category_from", - "column_break_19", - "add_taxes_from_item_tax_template", - "period_closing_settings_section", - "acc_frozen_upto", - "frozen_accounts_modifier", - "column_break_4", + "report_setting_section", + "use_custom_cash_flow", "deferred_accounting_settings_section", "book_deferred_entries_based_on", "column_break_18", "automatically_process_deferred_accounting_entry", "book_deferred_entries_via_journal_entry", "submit_journal_entries", + "tax_settings_section", + "determine_address_tax_category_from", + "column_break_19", + "add_taxes_from_item_tax_template", "print_settings", "show_inclusive_tax_in_print", "column_break_12", @@ -43,8 +38,25 @@ "currency_exchange_section", "allow_stale", "stale_days", - "report_settings_sb", - "use_custom_cash_flow" + "invoicing_settings_tab", + "accounts_transactions_settings_section", + "over_billing_allowance", + "column_break_11", + "role_allowed_to_over_bill", + "credit_controller", + "make_payment_via_journal_entry", + "pos_tab", + "pos_setting_section", + "post_change_gl_entries", + "assets_tab", + "asset_settings_section", + "book_asset_depreciation_entry_automatically", + "closing_settings_tab", + "period_closing_settings_section", + "acc_frozen_upto", + "column_break_25", + "frozen_accounts_modifier", + "report_settings_sb" ], "fields": [ { @@ -70,10 +82,6 @@ "label": "Determine Address Tax Category From", "options": "Billing Address\nShipping Address" }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, { "fieldname": "credit_controller", "fieldtype": "Link", @@ -83,6 +91,7 @@ }, { "default": "0", + "description": "Enabling ensure each Sales Invoice has a unique value in Supplier Invoice No. field", "fieldname": "check_supplier_invoice_uniqueness", "fieldtype": "Check", "label": "Check Supplier Invoice Number Uniqueness" @@ -168,7 +177,7 @@ "description": "Only select this if you have set up the Cash Flow Mapper documents", "fieldname": "use_custom_cash_flow", "fieldtype": "Check", - "label": "Use Custom Cash Flow Format" + "label": "Enable Custom Cash Flow Format" }, { "default": "0", @@ -241,7 +250,7 @@ { "fieldname": "accounts_transactions_settings_section", "fieldtype": "Section Break", - "label": "Transactions Settings" + "label": "Credit Limit Settings" }, { "fieldname": "column_break_11", @@ -272,9 +281,72 @@ }, { "default": "0", + "description": "Learn about Common Party", "fieldname": "enable_common_party_accounting", "fieldtype": "Check", "label": "Enable Common Party Accounting" + }, + { + "fieldname": "enable_features_section", + "fieldtype": "Section Break", + "label": "Invoice Cancellation" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_25", + "fieldtype": "Column Break" + }, + { + "fieldname": "asset_settings_section", + "fieldtype": "Section Break", + "label": "Asset Settings" + }, + { + "fieldname": "invoicing_settings_tab", + "fieldtype": "Tab Break", + "label": "Credit Limits" + }, + { + "fieldname": "assets_tab", + "fieldtype": "Tab Break", + "label": "Assets" + }, + { + "fieldname": "closing_settings_tab", + "fieldtype": "Tab Break", + "label": "Accounts Closing" + }, + { + "fieldname": "pos_setting_section", + "fieldtype": "Section Break", + "label": "POS Setting" + }, + { + "fieldname": "invoice_and_billing_tab", + "fieldtype": "Tab Break", + "label": "Invoice and Billing" + }, + { + "fieldname": "invoicing_features_section", + "fieldtype": "Section Break", + "label": "Invoicing Features" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "fieldname": "pos_tab", + "fieldtype": "Tab Break", + "label": "POS" + }, + { + "fieldname": "report_setting_section", + "fieldtype": "Section Break", + "label": "Report Setting" } ], "icon": "icon-cog", @@ -282,7 +354,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-10-11 17:42:36.427699", + "modified": "2022-02-04 12:32:36.805652", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", @@ -309,5 +381,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index b828a43d3c..50321baa2e 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -6,14 +6,17 @@ "document_type": "Other", "engine": "InnoDB", "field_order": [ + "supplier_and_price_defaults_section", "supp_master_name", "supplier_group", + "column_break_4", "buying_price_list", "maintain_same_rate_action", "role_to_override_stop_action", - "column_break_3", + "transaction_settings_section", "po_required", "pr_required", + "column_break_12", "maintain_same_rate", "allow_multiple_items", "bill_for_rejected_quantity_in_purchase_invoice", @@ -42,10 +45,6 @@ "label": "Default Buying Price List", "options": "Price List" }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, { "fieldname": "po_required", "fieldtype": "Select", @@ -73,7 +72,7 @@ { "fieldname": "subcontract", "fieldtype": "Section Break", - "label": "Subcontract" + "label": "Subcontracting Settings" }, { "default": "Material Transferred for Subcontract", @@ -116,6 +115,24 @@ "fieldname": "bill_for_rejected_quantity_in_purchase_invoice", "fieldtype": "Check", "label": "Bill for Rejected Quantity in Purchase Invoice" + }, + { + "fieldname": "supplier_and_price_defaults_section", + "fieldtype": "Section Break", + "label": "Supplier and Price Defaults" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_settings_section", + "fieldtype": "Section Break", + "label": "Transaction Settings" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", @@ -123,7 +140,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-08 19:26:23.548837", + "modified": "2022-01-27 17:57:58.367048", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -141,5 +158,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 27bc541d62..7c4a3f63dc 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -80,7 +80,7 @@ "description": "How often should Project and Company be updated based on Sales Transactions?", "fieldname": "sales_update_frequency", "fieldtype": "Select", - "label": "Sales Update Frequency", + "label": "Sales Update Frequency in Company and Project", "options": "Each Transaction\nDaily\nMonthly", "reqd": 1 }, @@ -171,7 +171,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-13 12:32:17.004404", + "modified": "2022-02-04 15:41:59.939261", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -189,5 +189,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 33d9a6ce41..438ec16096 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -5,35 +5,41 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "defaults_tab", "item_defaults_section", "item_naming_by", "item_group", "stock_uom", - "default_warehouse", "column_break_4", - "valuation_method", + "default_warehouse", "sample_retention_warehouse", - "use_naming_series", - "naming_series_prefix", + "valuation_method", + "price_list_defaults_section", + "auto_insert_price_list_rate_if_missing", + "column_break_12", + "update_existing_price_list_rate", + "stock_validations_tab", "section_break_9", "over_delivery_receipt_allowance", - "role_allowed_to_over_deliver_receive", "mr_qty_allowance", - "column_break_12", - "auto_insert_price_list_rate_if_missing", - "update_existing_price_list_rate", + "column_break_121", + "role_allowed_to_over_deliver_receive", "allow_negative_stock", "show_barcode_field", "clean_description_html", "quality_inspection_settings_section", "action_if_quality_inspection_is_not_submitted", - "column_break_21", + "column_break_23", "action_if_quality_inspection_is_rejected", + "serial_and_batch_item_settings_tab", "section_break_7", "automatically_set_serial_nos_based_on_fifo", "set_qty_in_transactions_based_on_serial_no_input", "column_break_10", "disable_serial_no_and_batch_selector", + "use_naming_series", + "naming_series_prefix", + "stock_planning_tab", "auto_material_request", "auto_indent", "column_break_27", @@ -42,6 +48,7 @@ "allow_from_dn", "column_break_31", "allow_from_pr", + "stock_closing_tab", "control_historical_stock_transactions_section", "stock_frozen_upto", "stock_frozen_upto_days", @@ -122,7 +129,7 @@ { "fieldname": "section_break_7", "fieldtype": "Section Break", - "label": "Serialised and Batch Setting" + "label": "Serial & Batch Item Settings" }, { "default": "0", @@ -275,10 +282,6 @@ "fieldtype": "Section Break", "label": "Quality Inspection Settings" }, - { - "fieldname": "column_break_21", - "fieldtype": "Column Break" - }, { "default": "Stop", "fieldname": "action_if_quality_inspection_is_rejected", @@ -298,6 +301,44 @@ "fieldname": "update_existing_price_list_rate", "fieldtype": "Check", "label": "Update Existing Price List Rate" + }, + { + "fieldname": "defaults_tab", + "fieldtype": "Tab Break", + "label": "Defaults" + }, + { + "fieldname": "stock_validations_tab", + "fieldtype": "Tab Break", + "label": "Stock Validations" + }, + { + "fieldname": "stock_planning_tab", + "fieldtype": "Tab Break", + "label": "Stock Planning" + }, + { + "fieldname": "stock_closing_tab", + "fieldtype": "Tab Break", + "label": "Stock Closing" + }, + { + "fieldname": "serial_and_batch_item_settings_tab", + "fieldtype": "Tab Break", + "label": "Serial & Batch Item" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "price_list_defaults_section", + "fieldtype": "Section Break", + "label": "Price List Defaults" + }, + { + "fieldname": "column_break_121", + "fieldtype": "Column Break" } ], "icon": "icon-cog", @@ -305,7 +346,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-11-06 19:40:02.183592", + "modified": "2022-02-04 15:33:43.692736", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", @@ -324,5 +365,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file From 5e6227e3d87cc54e842cb6f71f18083055c9a51a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 7 Feb 2022 13:19:08 +0530 Subject: [PATCH 041/112] fix(ux): make stock entry type the title field (#29674) --- .../doctype/stock_entry/stock_entry.json | 19 ++++++------------- .../stock/doctype/stock_entry/stock_entry.py | 9 --------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 2f37778896..c38dfaa1c8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -8,7 +8,6 @@ "engine": "InnoDB", "field_order": [ "items_section", - "title", "naming_series", "stock_entry_type", "outgoing_stock_entry", @@ -83,14 +82,6 @@ "fieldtype": "Section Break", "oldfieldtype": "Section Break" }, - { - "fieldname": "title", - "fieldtype": "Data", - "hidden": 1, - "label": "Title", - "no_copy": 1, - "print_hide": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -353,9 +344,9 @@ }, { "fieldname": "scan_barcode", - "options": "Barcode", "fieldtype": "Data", - "label": "Scan Barcode" + "label": "Scan Barcode", + "options": "Barcode" }, { "allow_bulk_edit": 1, @@ -628,10 +619,11 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-08-20 19:19:31.514846", + "modified": "2022-02-07 12:55:14.614077", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -698,6 +690,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "title", + "states": [], + "title_field": "stock_entry_type", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index c51c9bc5f4..a2ef7b42be 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -76,7 +76,6 @@ class StockEntry(StockController): self.validate_posting_time() self.validate_purpose() - self.set_title() self.validate_item() self.validate_customer_provided_item() self.validate_qty() @@ -1835,14 +1834,6 @@ class StockEntry(StockController): return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) - def set_title(self): - if frappe.flags.in_import and self.title: - # Allow updating title during data import/update - return - - self.title = self.purpose - - @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): if isinstance(items, str): From 8ece2845f2dd6cc382c21c7794ee9d533cf4ea9b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 7 Feb 2022 13:50:31 +0530 Subject: [PATCH 042/112] fix: Add disbursement accounts to tests --- .../doctype/loan_application/test_loan_application.py | 2 +- .../doctype/loan_disbursement/test_loan_disbursement.py | 4 ++-- .../loan_interest_accrual/test_loan_interest_accrual.py | 4 ++-- erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py | 1 + erpnext/payroll/doctype/salary_slip/test_salary_slip.py | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/erpnext/loan_management/doctype/loan_application/test_loan_application.py b/erpnext/loan_management/doctype/loan_application/test_loan_application.py index d367e92ac4..640709c095 100644 --- a/erpnext/loan_management/doctype/loan_application/test_loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/test_loan_application.py @@ -15,7 +15,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( class TestLoanApplication(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', + create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Disbursement Account - _TC', 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC', 'Repay Over Number of Periods', 18) self.applicant = make_employee("kate_loan@loan.com", "_Test Company") make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant, currency='INR') diff --git a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py index 94ec84ea5d..10be750b44 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py @@ -44,8 +44,8 @@ class TestLoanDisbursement(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py index 46aaaad9fd..e8c77506fc 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py @@ -30,8 +30,8 @@ class TestLoanInterestAccrual(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index 4f097fa2c3..5f836db2f0 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -214,6 +214,7 @@ class TestPayrollEntry(unittest.TestCase): create_loan_type("Car Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 597fd5a250..30b604b2c0 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -370,6 +370,7 @@ class TestSalarySlip(unittest.TestCase): create_loan_type("Car Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', From ef69d1fd385bfe740ce1765f1a4998d27456ce79 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 7 Feb 2022 17:00:30 +0530 Subject: [PATCH 043/112] test: Add test case for repayment against partially disbursed loans --- .../loan_management/doctype/loan/test_loan.py | 23 +++++++++++++++++++ .../doctype/loan_repayment/loan_repayment.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index 1676c218c8..cb7337e8d6 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -679,6 +679,29 @@ class TestLoan(unittest.TestCase): loan.load_from_db() self.assertEqual(loan.status, "Loan Closure Requested") + def test_loan_repayment_against_partially_disbursed_loan(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + first_date = '2019-10-01' + last_date = '2019-10-30' + + make_loan_disbursement_entry(loan.name, loan.loan_amount/2, disbursement_date=first_date) + + loan.load_from_db() + + self.assertEqual(loan.status, "Partially Disbursed") + create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + flt(loan.loan_amount/3)) + def test_loan_amount_write_off(self): pledge = [{ "loan_security": "Test Security 1", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 204fce797d..acf3a655de 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -153,7 +153,7 @@ class LoanRepayment(AccountsController): def mark_as_unpaid(self): loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid', - 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable', + 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable', 'written_off_amount'], as_dict=1) no_of_repayments = len(self.repayment_details) From 72a812f18bfd27842156d7b1afb1f301fbead7ed Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Mon, 7 Feb 2022 19:43:29 +0530 Subject: [PATCH 044/112] fix: use item_code instead of parent field in bom_stock_calculated report (#29684) --- .../report/bom_stock_calculated/bom_stock_calculated.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py index 090a3e74fc..2693352324 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py @@ -89,10 +89,10 @@ def get_bom_stock(filters): GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1) def get_manufacturer_records(): - details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"]) + details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"]) manufacture_details = frappe._dict() for detail in details: - dic = manufacture_details.setdefault(detail.get('parent'), {}) + dic = manufacture_details.setdefault(detail.get('item_code'), {}) dic.setdefault('manufacturer', []).append(detail.get('manufacturer')) dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no')) From eab10a13e70994f782b2bddc0cc7c99be8837cf9 Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Mon, 7 Feb 2022 22:12:27 +0530 Subject: [PATCH 045/112] fix: Conflicts --- erpnext/patches.txt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ffd85a6351..5a8f8ef6f8 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -324,11 +324,6 @@ erpnext.patches.v13_0.update_tax_category_for_rcm execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning -<<<<<<< HEAD -<<<<<<< HEAD -======= -erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit ->>>>>>> fedeb2a70f (chore: remove patch) erpnext.patches.v13_0.hospitality_deprecation_warning erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v13_0.update_asset_quantity_field @@ -347,6 +342,3 @@ erpnext.patches.v14_0.restore_einvoice_fields erpnext.patches.v13_0.update_sane_transfer_against erpnext.patches.v12_0.add_company_link_to_einvoice_settings erpnext.patches.v14_0.migrate_cost_center_allocations -======= -erpnext.patches.v13_0.set_billed_amount_in_returned_dn ->>>>>>> fc65a3d989 (feat: add patch) From c56d07dee3f0c35e211424d82fec628aea22d2e9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 5 Feb 2022 17:28:21 +0530 Subject: [PATCH 046/112] fix: consider packed items too when reposting --- erpnext/controllers/stock_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 8d17683953..92f8ff0d20 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -184,8 +184,8 @@ class StockController(AccountsController): def get_items_and_warehouses(self): items, warehouses = [], [] - if hasattr(self, "items"): - item_doclist = self.get("items") + if hasattr(self, "items") or hasattr(self, "packed_items"): + item_doclist = (self.get("items") or []) + (self.get("packed_items") or []) elif self.doctype == "Stock Reconciliation": item_doclist = [] data = json.loads(self.reconciliation_json) From e6ab8df8f2c48932a7368c5ac69ebcac14cf015c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 13:02:34 +0530 Subject: [PATCH 047/112] refactor: simplify get_items_and_warehouses Also remove dead code related to stock reconciliation_json. --- erpnext/controllers/stock_controller.py | 40 +++++++++++-------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 92f8ff0d20..9be5c0d03f 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -3,6 +3,7 @@ import json from collections import defaultdict +from typing import List, Tuple import frappe from frappe import _ @@ -181,33 +182,28 @@ class StockController(AccountsController): return details - def get_items_and_warehouses(self): - items, warehouses = [], [] + def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]: + """Get list of items and warehouses affected by a transaction""" - if hasattr(self, "items") or hasattr(self, "packed_items"): - item_doclist = (self.get("items") or []) + (self.get("packed_items") or []) - elif self.doctype == "Stock Reconciliation": - item_doclist = [] - data = json.loads(self.reconciliation_json) - for row in data[data.index(self.head_row)+1:]: - d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row)) - item_doclist.append(d) + if not (hasattr(self, "items") or hasattr(self, "packed_items")): + return [], [] - if item_doclist: - for d in item_doclist: - if d.item_code and d.item_code not in items: - items.append(d.item_code) + item_rows = (self.get("items") or []) + (self.get("packed_items") or []) - if d.get("warehouse") and d.warehouse not in warehouses: - warehouses.append(d.warehouse) + items = {d.item_code for d in item_rows if d.item_code} - if self.doctype == "Stock Entry": - if d.get("s_warehouse") and d.s_warehouse not in warehouses: - warehouses.append(d.s_warehouse) - if d.get("t_warehouse") and d.t_warehouse not in warehouses: - warehouses.append(d.t_warehouse) + warehouses = set() + for d in item_rows: + if d.get("warehouse"): + warehouses.add(d.warehouse) - return items, warehouses + if self.doctype == "Stock Entry": + if d.get("s_warehouse"): + warehouses.add(d.s_warehouse) + if d.get("t_warehouse"): + warehouses.add(d.t_warehouse) + + return list(items), list(warehouses) def get_stock_ledger_details(self): stock_ledger = {} From 1022db04745b3c6f16710b43720fef4f0fd0d295 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 13:29:40 +0530 Subject: [PATCH 048/112] fix: merge stock ledger item warehouse with doc's --- .../repost_item_valuation/repost_item_valuation.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 01c5e3e4e2..977d470995 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -13,7 +13,7 @@ from erpnext.accounts.utils import ( check_if_stock_and_account_balance_synced, update_gl_entries_after, ) -from erpnext.stock.stock_ledger import repost_future_sle +from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle class RepostItemValuation(Document): @@ -138,13 +138,20 @@ def repost_gl_entries(doc): if doc.based_on == 'Transaction': ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no) - items, warehouses = ref_doc.get_items_and_warehouses() + doc_items, doc_warehouses = ref_doc.get_items_and_warehouses() + + sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no) + sle_items = [sle.item_code for sle in sles] + sle_warehouse = [sle.warehouse for sle in sles] + + items = list(set(doc_items).union(set(sle_items))) + warehouses = list(set(doc_warehouses).union(set(sle_warehouse))) else: items = [doc.item_code] warehouses = [doc.warehouse] update_gl_entries_after(doc.posting_date, doc.posting_time, - warehouses, items, company=doc.company) + for_warehouses=warehouses, for_items=items, company=doc.company) def notify_error_to_stock_managers(doc, traceback): recipients = get_users_with_role("Stock Manager") From 853e658dccf1a71fad03e23bf3b7d8f9d0784c37 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 13:42:44 +0530 Subject: [PATCH 049/112] test: move bundle info to class variables --- .../doctype/packed_item/test_packed_item.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 5cbaa1ea66..fcb4727f6b 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -5,6 +5,7 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestCase, change_settings @@ -12,31 +13,29 @@ class TestPackedItem(ERPNextTestCase): "Test impact on Packed Items table in various scenarios." @classmethod def setUpClass(cls) -> None: - make_item("_Test Product Bundle X", {"is_stock_item": 0}) - make_item("_Test Bundle Item 1", {"is_stock_item": 1}) - make_item("_Test Bundle Item 2", {"is_stock_item": 1}) + cls.bundle = "_Test Product Bundle X" + cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] + make_item(cls.bundle, {"is_stock_item": 0}) + for item in cls.bundle_items: + make_item(item, {"is_stock_item": 1}) + make_item("_Test Normal Stock Item", {"is_stock_item": 1}) - make_product_bundle( - "_Test Product Bundle X", - ["_Test Bundle Item 1", "_Test Bundle Item 2"], - qty=2 - ) + make_product_bundle(cls.bundle, cls.bundle_items, qty=2) def test_adding_bundle_item(self): "Test impact on packed items if bundle item row is added." - so = make_sales_order(item_code = "_Test Product Bundle X", qty=1, + so = make_sales_order(item_code = self.bundle, qty=1, do_not_submit=True) self.assertEqual(so.items[0].qty, 1) self.assertEqual(len(so.packed_items), 2) - self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1") + self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0]) self.assertEqual(so.packed_items[0].qty, 2) def test_updating_bundle_item(self): "Test impact on packed items if bundle item row is updated." - so = make_sales_order(item_code = "_Test Product Bundle X", qty=1, - do_not_submit=True) + so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True) so.items[0].qty = 2 # change qty so.save() @@ -55,7 +54,7 @@ class TestPackedItem(ERPNextTestCase): so_items = [] for qty in [2, 4, 6, 8]: so_items.append({ - "item_code": "_Test Product Bundle X", + "item_code": self.bundle, "qty": qty, "rate": 400, "warehouse": "_Test Warehouse - _TC" @@ -66,7 +65,7 @@ class TestPackedItem(ERPNextTestCase): # check alternate rows for qty self.assertEqual(len(so.packed_items), 8) - self.assertEqual(so.packed_items[1].item_code, "_Test Bundle Item 2") + self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1]) self.assertEqual(so.packed_items[1].qty, 4) self.assertEqual(so.packed_items[3].qty, 8) self.assertEqual(so.packed_items[5].qty, 12) @@ -94,8 +93,7 @@ class TestPackedItem(ERPNextTestCase): @change_settings("Selling Settings", {"editable_bundle_item_rates": 1}) def test_bundle_item_cumulative_price(self): "Test if Bundle Item rate is cumulative from packed items." - so = make_sales_order(item_code = "_Test Product Bundle X", qty=2, - do_not_submit=True) + so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True) so.packed_items[0].rate = 150 so.packed_items[1].rate = 200 @@ -109,7 +107,7 @@ class TestPackedItem(ERPNextTestCase): so_items = [] for qty in [2, 4]: so_items.append({ - "item_code": "_Test Product Bundle X", + "item_code": self.bundle, "qty": qty, "rate": 400, "warehouse": "_Test Warehouse - _TC" @@ -124,4 +122,4 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(len(dn.packed_items), 4) self.assertEqual(dn.packed_items[2].qty, 6) - self.assertEqual(dn.packed_items[3].qty, 6) \ No newline at end of file + self.assertEqual(dn.packed_items[3].qty, 6) From 699519f7b69b0b5eaa53a0db2fb35857b080261c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 14:19:58 +0530 Subject: [PATCH 050/112] test: product bundle reposting --- .../doctype/packed_item/test_packed_item.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index fcb4727f6b..2521ac9fe7 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -1,10 +1,13 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from frappe.utils import add_to_date, nowdate + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestCase, change_settings @@ -13,6 +16,7 @@ class TestPackedItem(ERPNextTestCase): "Test impact on Packed Items table in various scenarios." @classmethod def setUpClass(cls) -> None: + super().setUpClass() cls.bundle = "_Test Product Bundle X" cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] make_item(cls.bundle, {"is_stock_item": 0}) @@ -123,3 +127,32 @@ class TestPackedItem(ERPNextTestCase): self.assertEqual(len(dn.packed_items), 4) self.assertEqual(dn.packed_items[2].qty, 6) self.assertEqual(dn.packed_items[3].qty, 6) + + def test_reposting_packed_items(self): + warehouse = "Stores - TCP1" + company = "_Test Company with perpetual inventory" + + today = nowdate() + yesterday = add_to_date(today, days=-1, as_string=True) + + for item in self.bundle_items: + make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today) + + so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse) + + dn = make_delivery_note(so.name) + dn.save() + dn.submit() + + gles = get_gl_entries(dn.doctype, dn.name) + credit_before_repost = sum(gle.credit for gle in gles) + + # backdated stock entry + for item in self.bundle_items: + make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday) + + # assert correct reposting + gles = get_gl_entries(dn.doctype, dn.name) + credit_after_reposting = sum(gle.credit for gle in gles) + self.assertNotEqual(credit_before_repost, credit_after_reposting) + self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost) From 43f8ee1dd1525b6cf3e88154bec314aca7b23ca5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 14:28:55 +0530 Subject: [PATCH 051/112] chore: drop dead field from stock reconciliation --- .../stock_reconciliation.json | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json index 3402972bb8..a882a61e5a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json @@ -18,7 +18,6 @@ "items", "section_break_9", "expense_account", - "reconciliation_json", "column_break_13", "difference_amount", "amended_from", @@ -111,15 +110,6 @@ "label": "Cost Center", "options": "Cost Center" }, - { - "fieldname": "reconciliation_json", - "fieldtype": "Long Text", - "hidden": 1, - "label": "Reconciliation JSON", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "column_break_13", "fieldtype": "Column Break" @@ -155,7 +145,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2021-11-30 01:33:51.437194", + "modified": "2022-02-06 14:28:19.043905", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation", @@ -178,5 +168,6 @@ "search_fields": "posting_date", "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file From f82d7eb73fe6dd70a98fa6656e90ed9c6565be16 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 6 Feb 2022 15:18:00 +0530 Subject: [PATCH 052/112] test: commit item/warehouse creation to db --- .../doctype/stock_reconciliation/test_stock_reconciliation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 428370cc75..86af0a0cf3 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings class TestStockReconciliation(ERPNextTestCase): @classmethod def setUpClass(cls): - super().setUpClass() create_batch_or_serial_no_items() + super().setUpClass() frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) def tearDown(self): From a3e69cf75d27198132d05c7c10475a0297b1e190 Mon Sep 17 00:00:00 2001 From: Mohammed Yusuf Shaikh <49878143+mohammedyusufshaikh@users.noreply.github.com> Date: Tue, 8 Feb 2022 01:00:37 +0530 Subject: [PATCH 053/112] feat: Bulk Transaction Processing (#28580) * feat: Bulk Transaction Processing * fix: add flags to ignore validations and exception handling correction * fix: remove duplicate code, added logger functionality and improved notifications * fix: linting and sider issues * test: added tests * fix: linter issues * fix: failing test case * fix: sider issues and test cases * refactor: mapping function calls to create order/invoice * fix: added more test cases to increase coverage * fix: test cases * fix: sider issue * fix: rename doctype, improve formatting and minor refactor * fix: update doctype name in hooks and sider issues * fix: entry log test case * fix: typos, translations and company name in tests * fix: linter issues and translations * fix: linter issue * fix: split into separate function for marking failed transaction * fix: typos, retry failed transaction logic and make log read only * fix: hide retry button when no failed transactions and remove test cases not rrelevant * fix: sider issues and indentation to tabs Co-authored-by: Ankush Menat --- .../test_bulk_transaction_processing.js | 44 ++++ .../purchase_invoice/purchase_invoice_list.js | 10 + .../sales_invoice/sales_invoice_list.js | 12 +- erpnext/bulk_transaction/__init__.py | 0 erpnext/bulk_transaction/doctype/__init__.py | 0 .../doctype/bulk_transaction_log/__init__.py | 0 .../bulk_transaction_log.js | 34 +++ .../bulk_transaction_log.json | 51 +++++ .../bulk_transaction_log.py | 66 ++++++ .../test_bulk_transaction_log.py | 81 +++++++ .../bulk_transaction_log_detail/__init__.py | 0 .../bulk_transaction_log_detail.json | 86 ++++++++ .../bulk_transaction_log_detail.py | 9 + .../purchase_order/purchase_order_list.js | 16 +- .../supplier_quotation/supplier_quotation.py | 20 ++ .../supplier_quotation_list.js | 10 + erpnext/hooks.py | 3 +- erpnext/modules.txt | 1 + erpnext/public/build.json | 3 +- .../public/js/bulk_transaction_processing.js | 30 +++ erpnext/public/js/erpnext.bundle.js | 1 + .../doctype/quotation/quotation_list.js | 8 + .../doctype/sales_order/sales_order_list.js | 14 +- .../doctype/delivery_note/delivery_note.py | 11 + .../delivery_note/delivery_note_list.js | 14 +- .../purchase_receipt/purchase_receipt_list.js | 8 + .../ui_test_bulk_transaction_processing.py | 21 ++ erpnext/utilities/bulk_transaction.py | 201 ++++++++++++++++++ 28 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 cypress/integration/test_bulk_transaction_processing.js create mode 100644 erpnext/bulk_transaction/__init__.py create mode 100644 erpnext/bulk_transaction/doctype/__init__.py create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json create mode 100644 erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py create mode 100644 erpnext/public/js/bulk_transaction_processing.js create mode 100644 erpnext/tests/ui_test_bulk_transaction_processing.py create mode 100644 erpnext/utilities/bulk_transaction.py diff --git a/cypress/integration/test_bulk_transaction_processing.js b/cypress/integration/test_bulk_transaction_processing.js new file mode 100644 index 0000000000..428ec5100b --- /dev/null +++ b/cypress/integration/test_bulk_transaction_processing.js @@ -0,0 +1,44 @@ +describe("Bulk Transaction Processing", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + it("Creates To Sales Order", () => { + cy.visit("/app/sales-order"); + cy.url().should("include", "/sales-order"); + cy.window() + .its("frappe.csrf_token") + .then((csrf_token) => { + return cy + .request({ + url: "/api/method/erpnext.tests.ui_test_bulk_transaction_processing.create_records", + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrf_token, + }, + timeout: 60000, + }) + .then((res) => { + expect(res.status).eq(200); + }); + }); + cy.wait(5000); + cy.get( + ".list-row-head > .list-header-subject > .list-row-col > .list-check-all" + ).check({ force: true }); + cy.wait(3000); + cy.get(".actions-btn-group > .btn-primary").click({ force: true }); + cy.wait(3000); + cy.get(".dropdown-menu-right > .user-action > .dropdown-item") + .contains("Sales Invoice") + .click({ force: true }); + cy.wait(3000); + cy.get(".modal-content > .modal-footer > .standard-actions") + .contains("Yes") + .click({ force: true }); + cy.contains("Creation of Sales Invoice successful"); + }); +}); diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js index f6ff83add8..82d00308db 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js @@ -56,4 +56,14 @@ frappe.listview_settings["Purchase Invoice"] = { ]; } }, + + onload: function(listview) { + listview.page.add_action_item(__("Purchase Receipt"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Purchase Receipt"); + }); + + listview.page.add_action_item(__("Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment"); + }); + } }; diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js index 06e6f51183..1130284ecc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js @@ -21,5 +21,15 @@ frappe.listview_settings['Sales Invoice'] = { }; return [__(doc.status), status_colors[doc.status], "status,=,"+doc.status]; }, - right_column: "grand_total" + right_column: "grand_total", + + onload: function(listview) { + listview.page.add_action_item(__("Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Delivery Note"); + }); + + listview.page.add_action_item(__("Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment"); + }); + } }; diff --git a/erpnext/bulk_transaction/__init__.py b/erpnext/bulk_transaction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/bulk_transaction/doctype/__init__.py b/erpnext/bulk_transaction/doctype/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js new file mode 100644 index 0000000000..a739cc3730 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js @@ -0,0 +1,34 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Bulk Transaction Log', { + + before_load: function(frm) { + query(frm); + }, + + refresh: function(frm) { + frm.disable_save(); + frm.add_custom_button(__('Retry Failed Transactions'), ()=>{ + frappe.confirm(__("Retry Failing Transactions ?"), ()=>{ + query(frm); + } + ); + }); + } +}); + +function query(frm) { + frappe.call({ + method: "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", + args: { + log_date: frm.doc.log_date + } + }).then((r) => { + if (r.message) { + frm.remove_custom_button("Retry Failed Transactions"); + } else { + frappe.show_alert(__("Retrying Failed Transactions"), 5); + } + }); +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json new file mode 100644 index 0000000000..da42cf1bd4 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-30 13:41:16.343827", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "log_date", + "logger_data" + ], + "fields": [ + { + "fieldname": "log_date", + "fieldtype": "Date", + "label": "Log Date", + "read_only": 1 + }, + { + "fieldname": "logger_data", + "fieldtype": "Table", + "label": "Logger Data", + "options": "Bulk Transaction Log Detail" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-02-03 17:23:02.935325", + "modified_by": "Administrator", + "module": "Bulk Transaction", + "name": "Bulk Transaction Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py new file mode 100644 index 0000000000..de7cde5a6d --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -0,0 +1,66 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import date + +import frappe +from frappe.model.document import Document + +from erpnext.utilities.bulk_transaction import task, update_logger + + +class BulkTransactionLog(Document): + pass + + +@frappe.whitelist() +def retry_failing_transaction(log_date=None): + btp = frappe.qb.DocType("Bulk Transaction Log Detail") + data = ( + frappe.qb.from_(btp) + .select(btp.transaction_name, btp.from_doctype, btp.to_doctype) + .distinct() + .where(btp.retried != 1) + .where(btp.transaction_status == "Failed") + .where(btp.date == log_date) + ).run(as_dict=True) + + if data: + if not log_date: + log_date = str(date.today()) + if len(data) > 10: + frappe.enqueue(job, queue="long", job_name="bulk_retry", data=data, log_date=log_date) + else: + job(data, log_date) + else: + return "No Failed Records" + +def job(data, log_date): + for d in data: + failed = [] + try: + frappe.db.savepoint("before_creation_of_record") + task(d.transaction_name, d.from_doctype, d.to_doctype) + except Exception as e: + frappe.db.rollback(save_point="before_creation_of_record") + failed.append(e) + update_logger( + d.transaction_name, + e, + d.from_doctype, + d.to_doctype, + status="Failed", + log_date=log_date, + restarted=1 + ) + + if not failed: + update_logger( + d.transaction_name, + None, + d.from_doctype, + d.to_doctype, + status="Success", + log_date=log_date, + restarted=1, + ) diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py new file mode 100644 index 0000000000..a78e697b6f --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py @@ -0,0 +1,81 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest +from datetime import date + +import frappe + +from erpnext.utilities.bulk_transaction import transaction_processing + + +class TestBulkTransactionLog(unittest.TestCase): + + def setUp(self): + create_company() + create_customer() + create_item() + + def test_for_single_record(self): + so_name = create_so() + transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") + data = frappe.db.get_list("Sales Invoice", filters = {"posting_date": date.today(), "customer": "Bulk Customer"}, fields=["*"]) + if not data: + self.fail("No Sales Invoice Created !") + + def test_entry_in_log(self): + so_name = create_so() + transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") + doc = frappe.get_doc("Bulk Transaction Log", str(date.today())) + for d in doc.get("logger_data"): + if d.transaction_name == so_name: + self.assertEqual(d.transaction_name, so_name) + self.assertEqual(d.transaction_status, "Success") + self.assertEqual(d.from_doctype, "Sales Order") + self.assertEqual(d.to_doctype, "Sales Invoice") + self.assertEqual(d.retried, 0) + + + +def create_company(): + if not frappe.db.exists('Company', '_Test Company'): + frappe.get_doc({ + 'doctype': 'Company', + 'company_name': '_Test Company', + 'country': 'India', + 'default_currency': 'INR' + }).insert() + +def create_customer(): + if not frappe.db.exists('Customer', 'Bulk Customer'): + frappe.get_doc({ + 'doctype': 'Customer', + 'customer_name': 'Bulk Customer' + }).insert() + +def create_item(): + if not frappe.db.exists("Item", "MK"): + frappe.get_doc({ + "doctype": "Item", + "item_code": "MK", + "item_name": "Milk", + "description": "Milk", + "item_group": "Products" + }).insert() + +def create_so(intent=None): + so = frappe.new_doc("Sales Order") + so.customer = "Bulk Customer" + so.company = "_Test Company" + so.transaction_date = date.today() + + so.set_warehouse = "Finished Goods - _TC" + so.append("items", { + "item_code": "MK", + "delivery_date": date.today(), + "qty": 10, + "rate": 80, + }) + so.insert() + so.submit() + return so.name \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json new file mode 100644 index 0000000000..8262caa020 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json @@ -0,0 +1,86 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-30 13:38:30.926047", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "transaction_name", + "date", + "time", + "transaction_status", + "error_description", + "from_doctype", + "to_doctype", + "retried" + ], + "fields": [ + { + "fieldname": "transaction_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Name", + "options": "from_doctype" + }, + { + "fieldname": "transaction_status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status", + "read_only": 1 + }, + { + "fieldname": "error_description", + "fieldtype": "Long Text", + "label": "Error Description", + "read_only": 1 + }, + { + "fieldname": "from_doctype", + "fieldtype": "Link", + "label": "From Doctype", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "to_doctype", + "fieldtype": "Link", + "label": "To Doctype", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date ", + "read_only": 1 + }, + { + "fieldname": "time", + "fieldtype": "Time", + "label": "Time", + "read_only": 1 + }, + { + "fieldname": "retried", + "fieldtype": "Int", + "label": "Retried", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-02-03 19:57:31.650359", + "modified_by": "Administrator", + "module": "Bulk Transaction", + "name": "Bulk Transaction Log Detail", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py new file mode 100644 index 0000000000..67795b9d49 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BulkTransactionLogDetail(Document): + pass diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js index 8413eb65c3..d7907e4274 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js @@ -29,8 +29,22 @@ frappe.listview_settings['Purchase Order'] = { listview.call_for_selected_items(method, { "status": "Closed" }); }); - listview.page.add_menu_item(__("Re-open"), function () { + listview.page.add_menu_item(__("Reopen"), function () { listview.call_for_selected_items(method, { "status": "Submitted" }); }); + + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Invoice"); + }); + + listview.page.add_action_item(__("Purchase Receipt"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Receipt"); + }); + + listview.page.add_action_item(__("Advance Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Advance Payment"); + }); + } }; diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index d65ab94a6d..171de7882d 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -142,6 +142,26 @@ def make_purchase_order(source_name, target_doc=None): return doclist +@frappe.whitelist() +def make_purchase_invoice(source_name, target_doc=None): + doc = get_mapped_doc("Supplier Quotation", source_name, { + "Supplier Quotation": { + "doctype": "Purchase Invoice", + "validation": { + "docstatus": ["=", 1], + } + }, + "Supplier Quotation Item": { + "doctype": "Purchase Invoice Item" + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges" + } + }, target_doc) + + return doc + + @frappe.whitelist() def make_quotation(source_name, target_doc=None): doclist = get_mapped_doc("Supplier Quotation", source_name, { diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js index 5ab6c980d0..73685caa0b 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js @@ -8,5 +8,15 @@ frappe.listview_settings['Supplier Quotation'] = { } else if(doc.status==="Expired") { return [__("Expired"), "gray", "status,=,Expired"]; } + }, + + onload: function(listview) { + listview.page.add_action_item(__("Purchase Order"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Order"); + }); + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Invoice"); + }); } }; diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 0e290384b4..d99f23ed64 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -341,7 +341,8 @@ scheduler_events = { "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts" ], "hourly_long": [ - "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries" + "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", + "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction" ], "daily": [ "erpnext.stock.reorder_item.reorder_item", diff --git a/erpnext/modules.txt b/erpnext/modules.txt index c5705c1763..8c79ee5c9a 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -21,4 +21,5 @@ Communication Loan Management Payroll Telephony +Bulk Transaction E-commerce diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 569910dd9d..91a752c291 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -39,7 +39,8 @@ "public/js/utils/dimension_tree_filter.js", "public/js/telephony.js", "public/js/templates/call_link.html", - "public/js/templates/node_card.html" + "public/js/templates/node_card.html", + "public/js/bulk_transaction_processing.js" ], "js/item-dashboard.min.js": [ "stock/dashboard/item_dashboard.html", diff --git a/erpnext/public/js/bulk_transaction_processing.js b/erpnext/public/js/bulk_transaction_processing.js new file mode 100644 index 0000000000..101f50c64a --- /dev/null +++ b/erpnext/public/js/bulk_transaction_processing.js @@ -0,0 +1,30 @@ +frappe.provide("erpnext.bulk_transaction_processing"); + +$.extend(erpnext.bulk_transaction_processing, { + create: function(listview, from_doctype, to_doctype) { + let checked_items = listview.get_checked_items(); + const doc_name = []; + checked_items.forEach((Item)=> { + if (Item.docstatus == 0) { + doc_name.push(Item.name); + } + }); + + let count_of_rows = checked_items.length; + frappe.confirm(__("Create {0} {1} ?", [count_of_rows, to_doctype]), ()=>{ + if (doc_name.length == 0) { + frappe.call({ + method: "erpnext.utilities.bulk_transaction.transaction_processing", + args: {data: checked_items, from_doctype: from_doctype, to_doctype: to_doctype} + }).then(()=> { + + }); + if (count_of_rows > 10) { + frappe.show_alert("Starting a background job to create {0} {1}", [count_of_rows, to_doctype]); + } + } else { + frappe.msgprint(__("Selected document must be in submitted state")); + } + }); + } +}); \ No newline at end of file diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 5259bdcc76..b3a68b3862 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -22,5 +22,6 @@ import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; import "./telephony"; import "./templates/call_link.html"; +import "./bulk_transaction_processing"; // import { sum } from 'frappe/public/utils/util.js' diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index b631685bd1..4c8f9c4f84 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -12,6 +12,14 @@ frappe.listview_settings['Quotation'] = { }; }; } + + listview.page.add_action_item(__("Sales Order"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order"); + }); + + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice"); + }); }, get_indicator: function(doc) { diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js index 26d96d59f2..4691190d2a 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_list.js +++ b/erpnext/selling/doctype/sales_order/sales_order_list.js @@ -16,7 +16,7 @@ frappe.listview_settings['Sales Order'] = { return [__("Overdue"), "red", "per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"]; } else if (flt(doc.grand_total) === 0) { - // not delivered (zero-amount order) + // not delivered (zeroount order) return [__("To Deliver"), "orange", "per_delivered,<,100|grand_total,=,0|status,!=,Closed"]; } else if (flt(doc.per_billed, 6) < 100) { @@ -48,5 +48,17 @@ frappe.listview_settings['Sales Order'] = { listview.call_for_selected_items(method, {"status": "Submitted"}); }); + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice"); + }); + + listview.page.add_action_item(__("Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note"); + }); + + listview.page.add_action_item(__("Advance Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Advance Payment"); + }); + } }; diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index c3247fbe3e..2a4d63954a 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -608,7 +608,18 @@ def make_packing_slip(source_name, target_doc=None): "validation": { "docstatus": ["=", 0] } + }, + + "Delivery Note Item": { + "doctype": "Packing Slip Item", + "field_map": { + "item_code": "item_code", + "item_name": "item_name", + "description": "description", + "qty": "qty", + } } + }, target_doc) return doclist diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index 0402898047..9e6f3bc932 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -14,7 +14,7 @@ frappe.listview_settings['Delivery Note'] = { return [__("Completed"), "green", "per_billed,=,100"]; } }, - onload: function (doclist) { + onload: function (listview) { const action = () => { const selected_docs = doclist.get_checked_items(); const docnames = doclist.get_checked_items(true); @@ -54,6 +54,16 @@ frappe.listview_settings['Delivery Note'] = { }; }; - doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); + // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); + + listview.page.add_action_item(__('Create Delivery Trip'), action); + + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Sales Invoice"); + }); + + listview.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Packing Slip"); + }); } }; diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js index 77711de93f..4029f0c127 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js @@ -13,5 +13,13 @@ frappe.listview_settings['Purchase Receipt'] = { } else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) { return [__("Completed"), "green", "per_billed,=,100"]; } + }, + + onload: function(listview) { + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Receipt", "Purchase Invoice"); + }); } + }; diff --git a/erpnext/tests/ui_test_bulk_transaction_processing.py b/erpnext/tests/ui_test_bulk_transaction_processing.py new file mode 100644 index 0000000000..d78689eb5b --- /dev/null +++ b/erpnext/tests/ui_test_bulk_transaction_processing.py @@ -0,0 +1,21 @@ +import frappe + +from erpnext.bulk_transaction.doctype.bulk_transaction_logger.test_bulk_transaction_logger import ( + create_company, + create_customer, + create_item, + create_so, +) + + +@frappe.whitelist() +def create_records(): + create_company() + create_customer() + create_item() + + gd = frappe.get_doc("Global Defaults") + gd.set("default_company", "Test Bulk") + gd.save() + frappe.clear_cache() + create_so() \ No newline at end of file diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py new file mode 100644 index 0000000000..64e2ff4218 --- /dev/null +++ b/erpnext/utilities/bulk_transaction.py @@ -0,0 +1,201 @@ +import json +from datetime import date, datetime + +import frappe +from frappe import _ + + +@frappe.whitelist() +def transaction_processing(data, from_doctype, to_doctype): + if isinstance(data, str): + deserialized_data = json.loads(data) + + else: + deserialized_data = data + + length_of_data = len(deserialized_data) + + if length_of_data > 10: + frappe.msgprint( + _("Started a background job to create {1} {0}").format(to_doctype, length_of_data) + ) + frappe.enqueue( + job, + deserialized_data=deserialized_data, + from_doctype=from_doctype, + to_doctype=to_doctype, + ) + else: + job(deserialized_data, from_doctype, to_doctype) + + +def job(deserialized_data, from_doctype, to_doctype): + failed_history = [] + i = 0 + for d in deserialized_data: + failed = [] + + try: + i += 1 + doc_name = d.get("name") + frappe.db.savepoint("before_creation_state") + task(doc_name, from_doctype, to_doctype) + + except Exception as e: + frappe.db.rollback(save_point="before_creation_state") + failed_history.append(e) + failed.append(e) + update_logger(doc_name, e, from_doctype, to_doctype, status="Failed", log_date=str(date.today())) + if not failed: + update_logger(doc_name, None, from_doctype, to_doctype, status="Success", log_date=str(date.today())) + + show_job_status(failed_history, deserialized_data, to_doctype) + + +def task(doc_name, from_doctype, to_doctype): + from erpnext.accounts.doctype.payment_entry import payment_entry + from erpnext.accounts.doctype.purchase_invoice import purchase_invoice + from erpnext.accounts.doctype.sales_invoice import sales_invoice + from erpnext.buying.doctype.purchase_order import purchase_order + from erpnext.buying.doctype.supplier_quotation import supplier_quotation + from erpnext.selling.doctype.quotation import quotation + from erpnext.selling.doctype.sales_order import sales_order + from erpnext.stock.doctype.delivery_note import delivery_note + from erpnext.stock.doctype.purchase_receipt import purchase_receipt + + mapper = { + "Sales Order": { + "Sales Invoice": sales_order.make_sales_invoice, + "Delivery Note": sales_order.make_delivery_note, + "Advance Payment": payment_entry.get_payment_entry, + }, + "Sales Invoice": { + "Delivery Note": sales_invoice.make_delivery_note, + "Payment": payment_entry.get_payment_entry, + }, + "Delivery Note": { + "Sales Invoice": delivery_note.make_sales_invoice, + "Packing Slip": delivery_note.make_packing_slip, + }, + "Quotation": { + "Sales Order": quotation.make_sales_order, + "Sales Invoice": quotation.make_sales_invoice, + }, + "Supplier Quotation": { + "Purchase Order": supplier_quotation.make_purchase_order, + "Purchase Invoice": supplier_quotation.make_purchase_invoice, + "Advance Payment": payment_entry.get_payment_entry, + }, + "Purchase Order": { + "Purchase Invoice": purchase_order.make_purchase_invoice, + "Purchase Receipt": purchase_order.make_purchase_receipt, + }, + "Purhcase Invoice": { + "Purchase Receipt": purchase_invoice.make_purchase_receipt, + "Payment": payment_entry.get_payment_entry, + }, + "Purchase Receipt": {"Purchase Invoice": purchase_receipt.make_purchase_invoice}, + } + if to_doctype in ['Advance Payment', 'Payment']: + obj = mapper[from_doctype][to_doctype](from_doctype, doc_name) + else: + obj = mapper[from_doctype][to_doctype](doc_name) + + obj.flags.ignore_validate = True + obj.insert(ignore_mandatory=True) + + +def check_logger_doc_exists(log_date): + return frappe.db.exists("Bulk Transaction Log", log_date) + + +def get_logger_doc(log_date): + return frappe.get_doc("Bulk Transaction Log", log_date) + + +def create_logger_doc(): + log_doc = frappe.new_doc("Bulk Transaction Log") + log_doc.set_new_name(set_name=str(date.today())) + log_doc.log_date = date.today() + + return log_doc + + +def append_data_to_logger(log_doc, doc_name, error, from_doctype, to_doctype, status, restarted): + row = log_doc.append("logger_data", {}) + row.transaction_name = doc_name + row.date = date.today() + now = datetime.now() + row.time = now.strftime("%H:%M:%S") + row.transaction_status = status + row.error_description = str(error) + row.from_doctype = from_doctype + row.to_doctype = to_doctype + row.retried = restarted + + +def update_logger(doc_name, e, from_doctype, to_doctype, status, log_date=None, restarted=0): + if not check_logger_doc_exists(log_date): + log_doc = create_logger_doc() + append_data_to_logger(log_doc, doc_name, e, from_doctype, to_doctype, status, restarted) + log_doc.insert() + else: + log_doc = get_logger_doc(log_date) + if record_exists(log_doc, doc_name, status): + append_data_to_logger( + log_doc, doc_name, e, from_doctype, to_doctype, status, restarted + ) + log_doc.save() + + +def show_job_status(failed_history, deserialized_data, to_doctype): + if not failed_history: + frappe.msgprint( + _("Creation of {0} successful").format(to_doctype), + title="Successful", + indicator="green", + ) + + if len(failed_history) != 0 and len(failed_history) < len(deserialized_data): + frappe.msgprint( + _("""Creation of {0} partially successful. + Check Bulk Transaction Log""").format( + to_doctype + ), + title="Partially successful", + indicator="orange", + ) + + if len(failed_history) == len(deserialized_data): + frappe.msgprint( + _("""Creation of {0} failed. + Check Bulk Transaction Log""").format( + to_doctype + ), + title="Failed", + indicator="red", + ) + + +def record_exists(log_doc, doc_name, status): + + record = mark_retrired_transaction(log_doc, doc_name) + + if record and status == "Failed": + return False + elif record and status == "Success": + return True + else: + return True + + +def mark_retrired_transaction(log_doc, doc_name): + record = 0 + for d in log_doc.get("logger_data"): + if d.transaction_name == doc_name and d.transaction_status == "Failed": + d.retried = 1 + record = record + 1 + + log_doc.save() + + return record \ No newline at end of file From 0ca60afc3fb7190cbba58ef42b84c51bffb9d660 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 8 Feb 2022 10:24:19 +0530 Subject: [PATCH 054/112] fix: ignore cancelled SLEs (#29679) --- erpnext/controllers/stock_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9be5c0d03f..c8e5eddfea 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -215,7 +215,7 @@ class StockController(AccountsController): from `tabStock Ledger Entry` where - voucher_type=%s and voucher_no=%s + voucher_type=%s and voucher_no=%s and is_cancelled = 0 """, (self.doctype, self.name), as_dict=True) for sle in stock_ledger_entries: From e93bb8a3364c04c8a30b47f681e53747140e4fd9 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 10:49:37 +0530 Subject: [PATCH 055/112] chore: show credit/debit-to account in error message --- .../accounts/doctype/purchase_invoice/purchase_invoice.py | 4 ++-- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 279557adc7..09bfe35023 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -178,8 +178,8 @@ class PurchaseInvoice(BuyingController): if self.supplier and account.account_type != "Payable": frappe.throw( - _("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.") - .format(frappe.bold("Credit To")), title=_("Invalid Account") + _("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.") + .format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account") ) self.party_account_currency = account.account_currency diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index bc443581e4..a161336bc2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -572,7 +572,10 @@ class SalesInvoice(SellingController): frappe.throw(msg, title=_("Invalid Account")) if self.customer and account.account_type != "Receivable": - msg = _("Please ensure {} account is a Receivable account.").format(frappe.bold("Debit To")) + " " + msg = _("Please ensure {} account {} is a Receivable account.").format( + frappe.bold("Debit To"), + frappe.bold(self.debit_to) + ) + " " msg += _("Change the account type to Receivable or select a different account.") frappe.throw(msg, title=_("Invalid Account")) From 0452d7de20a8eddc1403d20b5f6cfba12eb63e82 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 11:26:23 +0530 Subject: [PATCH 056/112] fix(pos): incorrect grand_total in case of inclusive taxes on item --- .../pos_invoice_merge_log.py | 26 +++- .../test_pos_invoice_merge_log.py | 115 ++++++++++++++++++ erpnext/controllers/taxes_and_totals.py | 3 + 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 0720d9b2e9..f372dd604c 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -84,12 +84,21 @@ class POSInvoiceMergeLog(Document): sales_invoice.set_posting_time = 1 sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.save() + self.write_off_fractional_amount(sales_invoice, data) sales_invoice.submit() self.consolidated_invoice = sales_invoice.name return sales_invoice.name + def write_off_fractional_amount(self, invoice, data): + pos_invoice_grand_total = sum(d.grand_total for d in data) + + if abs(pos_invoice_grand_total - invoice.grand_total) < 1: + + invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total) + invoice.save() + def process_merging_into_credit_note(self, data): credit_note = self.get_new_sales_invoice() credit_note.is_return = 1 @@ -102,6 +111,7 @@ class POSInvoiceMergeLog(Document): # TODO: return could be against multiple sales invoice which could also have been consolidated? # credit_note.return_against = self.consolidated_invoice credit_note.save() + self.write_off_fractional_amount(credit_note, data) credit_note.submit() self.consolidated_credit_note = credit_note.name @@ -135,9 +145,15 @@ class POSInvoiceMergeLog(Document): i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse): found = True i.qty = i.qty + item.qty + i.amount = i.amount + item.net_amount + i.net_amount = i.amount + i.base_amount = i.base_amount + item.base_net_amount + i.base_net_amount = i.base_amount if not found: item.rate = item.net_rate + item.amount = item.net_amount + item.base_amount = item.base_net_amount item.price_list_rate = 0 si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) items.append(si_item) @@ -169,10 +185,12 @@ class POSInvoiceMergeLog(Document): found = True if not found: payments.append(payment) - rounding_adjustment += doc.rounding_adjustment - rounded_total += doc.rounded_total - base_rounding_adjustment += doc.base_rounding_adjustment - base_rounded_total += doc.base_rounded_total + + if doc.rounding_adjustment or doc.base_rounding_adjustment: + rounding_adjustment += doc.rounding_adjustment + rounded_total += doc.rounded_total + base_rounding_adjustment += doc.base_rounding_adjustment + base_rounded_total += doc.base_rounded_total if loyalty_points_sum: diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 3555da83a4..928d26676d 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -150,3 +150,118 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + + + def test_consolidation_round_off_error_1(self): + ''' + Test case for bug: + Round off error in consolidated invoice creation if POS Invoice has inclusive tax + ''' + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + init_user_and_profile() + + inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv2.insert() + inv2.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 0) + self.assertEqual(consolidated_invoice.status, 'Paid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") + + def test_consolidation_round_off_error_2(self): + ''' + Test the same case as above but with an Unpaid POS Invoice + ''' + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + init_user_and_profile() + + inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv2.insert() + inv2.submit() + + inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True) + inv3.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000 + }) + inv3.insert() + inv3.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 800) + self.assertEqual(consolidated_invoice.status, 'Unpaid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 075e3e38fa..5d1856cfa9 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object): self.doc.conversion_rate = flt(self.doc.conversion_rate) def calculate_item_values(self): + if self.doc.get('is_consolidated'): + return + if not self.discount_amount_applied: for item in self.doc.get("items"): self.doc.round_floats_in(item) From 7116d7ae0eabe9c31e03b84466ba74751e9479a9 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 8 Feb 2022 12:40:33 +0530 Subject: [PATCH 057/112] fix: Set Pending Qty in Prod Plan after updating Work Order --- erpnext/manufacturing/doctype/production_plan/production_plan.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 4290ca3e4c..a6d4dfc16e 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -279,6 +279,7 @@ class ProductionPlan(Document): for data in self.po_items: if data.name == production_plan_item: data.produced_qty = produced_qty + data.pending_qty = flt(data.planned_qty - produced_qty) data.db_update() self.calculate_total_produced_qty() From 3969840ee8fef30742f9c834a91df79f9644afc5 Mon Sep 17 00:00:00 2001 From: Bhavesh Maheshwari <34086262+bhavesh95863@users.noreply.github.com> Date: Mon, 7 Feb 2022 20:11:39 +0530 Subject: [PATCH 058/112] fix: ignore rate validation for work order (cherry picked from commit f29aed7f7130b805810075db1e5e1003e997bef8) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a2ef7b42be..782fcf04a5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1115,7 +1115,7 @@ class StockEntry(StockController): self.set_actual_qty() self.update_items_for_process_loss() self.validate_customer_provided_item() - self.calculate_rate_and_amount() + self.calculate_rate_and_amount(raise_error_if_no_rate=False) def set_scrap_items(self): if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: From bb105a33b28e5b86152dce0732424b2197e5dd57 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 8 Feb 2022 11:40:40 +0530 Subject: [PATCH 059/112] test: validate on save instead of on creation (cherry picked from commit 0efd5577bd9bdc0bb900d4d23d85aa86fd09929a) --- erpnext/manufacturing/doctype/work_order/test_work_order.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index a399edda70..76978017a6 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -703,7 +703,8 @@ class TestWorkOrder(ERPNextTestCase): wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse, company=company) - self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture') + stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture')) + self.assertRaises(frappe.ValidationError, stock_entry.save) def test_wo_completion_with_pl_bom(self): from erpnext.manufacturing.doctype.bom.test_bom import ( From bd1555bd230c0932bc0b7476f1ca68092a697e51 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 8 Feb 2022 14:36:31 +0530 Subject: [PATCH 060/112] fix: handle carry forwarded leaves while checking for duplicate allocation --- .../test_leave_policy_assignment.py | 55 ++++++++++++++++++- erpnext/hr/utils.py | 7 ++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 3455baeb08..8c76ca1cc3 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -188,6 +188,58 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 3) + def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self): + from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation + + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -2))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # initial leave allocation = 5 + leave_allocation = create_leave_allocation( + employee=employee.name, + employee_name=employee.employee_name, + leave_type=leave_type.name, + from_date=add_months(getdate(), -12), + to_date=add_months(getdate(), -3), + new_leaves_allocated=5, + carry_forward=0) + leave_allocation.submit() + + # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding + frappe.flags.current_date = get_last_day(add_months(getdate(), -1)) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name, + "carry_forward": 1 + } + # carry forwarded leaves = 5, 3 leaves allocated for passed months + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + details = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True) + self.assertEqual(details.new_leaves_allocated, 2) + self.assertEqual(details.unused_leaves, 5) + self.assertEqual(details.total_leaves_allocated, 7) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import is_earned_leave_already_allocated + frappe.flags.current_date = get_last_day(getdate()) + + allocation = frappe.get_doc('Leave Allocation', details.name) + # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves + self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation)) + def tearDown(self): frappe.db.rollback() @@ -200,7 +252,8 @@ def create_earned_leave_type(leave_type): doctype="Leave Type", is_earned_leave=1, earned_leave_frequency="Monthly", - rounding=0.5 + rounding=0.5, + is_carry_forward=1 )).insert() diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 2a07e56b1d..7fd3a98e2d 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -312,7 +312,12 @@ def is_earned_leave_already_allocated(allocation, annual_allocation): leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, annual_allocation, leave_type_details, date_of_joining) - if allocation.total_leaves_allocated >= leaves_for_passed_months: + # exclude carry-forwarded leaves while checking for leave allocation for passed months + num_allocations = allocation.total_leaves_allocated + if allocation.unused_leaves: + num_allocations -= allocation.unused_leaves + + if num_allocations >= leaves_for_passed_months: return True return False From 0fc5d2278d2785385051d576e895c8286ad7a3a2 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 15:50:41 +0530 Subject: [PATCH 061/112] fix: currency in bank reconciliation chart --- .../bank_reconciliation_tool/bank_reconciliation_tool.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index 335f8502c7..dbf362234e 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); }, + onload: function (frm) { + frm.trigger('bank_account'); + }, + refresh: function (frm) { frappe.require("bank-reconciliation-tool.bundle.js", () => frm.trigger("make_reconciliation_tool") @@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { bank_account: function (frm) { frappe.db.get_value( "Bank Account", - frm.bank_account, + frm.doc.bank_account, "account", (r) => { frappe.db.get_value( From afc5c26d1c7ba2973f8e74d57029e78db550946b Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 16:04:08 +0530 Subject: [PATCH 062/112] fix(test): ignore stock validation --- .../pos_invoice_merge_log/test_pos_invoice_merge_log.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 928d26676d..fd1aaab264 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -158,6 +158,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): Round off error in consolidated invoice creation if POS Invoice has inclusive tax ''' frappe.db.sql("delete from `tabPOS Invoice`") + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: init_user_and_profile() @@ -205,12 +206,14 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 0) def test_consolidation_round_off_error_2(self): ''' Test the same case as above but with an Unpaid POS Invoice ''' frappe.db.sql("delete from `tabPOS Invoice`") + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: init_user_and_profile() @@ -265,3 +268,4 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 0) From 7326d57966d09ababc9fd02d32980dae8d51dc3c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 8 Feb 2022 17:14:11 +0530 Subject: [PATCH 063/112] fix: Earned Leave allocation based on joining date not working --- erpnext/hr/utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 7fd3a98e2d..ae4411b851 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -261,10 +261,10 @@ def allocate_earned_leaves(ignore_duplicates=False): from_date=allocation.from_date - if e_leave_type.based_on_date_of_joining_date: + if e_leave_type.based_on_date_of_joining: from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") - if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): + if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining): update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates) def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False): @@ -305,10 +305,13 @@ def is_earned_leave_already_allocated(allocation, annual_allocation): get_leave_type_details, ) + assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) + if assignment.assignment_based_on == "Joining Date": + return False + leave_type_details = get_leave_type_details() date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") - assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, annual_allocation, leave_type_details, date_of_joining) @@ -343,7 +346,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date): allocation.unused_leaves = 0 allocation.create_leave_ledger_entry() -def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date): +def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining): import calendar from dateutil import relativedelta @@ -354,7 +357,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining #last day of month last_day = calendar.monthrange(to_date.year, to_date.month)[1] - if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day): + if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day): if frequency == "Monthly": return True elif frequency == "Quarterly" and rd.months % 3: From c2b83a02837e8ab9c2e23596f22b7f75e420003f Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 17:07:51 +0530 Subject: [PATCH 064/112] fix(test): case if write off is calculated as negative amount --- .../pos_invoice_merge_log/pos_invoice_merge_log.py | 1 - .../test_pos_invoice_merge_log.py | 11 ++++++----- erpnext/controllers/taxes_and_totals.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index d98dec8b92..ddca68a57b 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -95,7 +95,6 @@ class POSInvoiceMergeLog(Document): pos_invoice_grand_total = sum(d.grand_total for d in data) if abs(pos_invoice_grand_total - invoice.grand_total) < 1: - invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total) invoice.save() diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index fd1aaab264..fc14161456 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -154,10 +154,10 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): def test_consolidation_round_off_error_1(self): ''' - Test case for bug: - Round off error in consolidated invoice creation if POS Invoice has inclusive tax + Test round off error in consolidated invoice creation if POS Invoice has inclusive tax ''' frappe.db.sql("delete from `tabPOS Invoice`") + allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: @@ -206,13 +206,14 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 0) + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', allow_negative_stock) def test_consolidation_round_off_error_2(self): ''' Test the same case as above but with an Unpaid POS Invoice ''' frappe.db.sql("delete from `tabPOS Invoice`") + allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: @@ -262,10 +263,10 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): inv.load_from_db() consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) self.assertEqual(consolidated_invoice.outstanding_amount, 800) - self.assertEqual(consolidated_invoice.status, 'Unpaid') + self.assertNotEqual(consolidated_invoice.status, 'Paid') finally: frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 0) + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', allow_negative_stock) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 5d1856cfa9..de1099ee28 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -650,12 +650,12 @@ class calculate_taxes_and_totals(object): def calculate_change_amount(self): self.doc.change_amount = 0.0 self.doc.base_change_amount = 0.0 + grand_total = self.doc.rounded_total or self.doc.grand_total + base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total if self.doc.doctype == "Sales Invoice" \ - and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \ + and self.doc.paid_amount > grand_total and not self.doc.is_return \ and any(d.type == "Cash" for d in self.doc.payments): - grand_total = self.doc.rounded_total or self.doc.grand_total - base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total self.doc.change_amount = flt(self.doc.paid_amount - grand_total + self.doc.write_off_amount, self.doc.precision("change_amount")) From 6fa406dd04d7538b38e076cb4636b5713994456d Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 8 Feb 2022 16:32:08 +0530 Subject: [PATCH 065/112] fix(test): pass price_list_rate only if pricing rule has to be removed --- .../doctype/pos_invoice/test_pos_invoice.py | 36 +++++++++++-------- .../doctype/pricing_rule/pricing_rule.py | 30 ++++++++++------ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index ba751c081b..cf8affdd01 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -586,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase): item_price.insert() pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) pr.save() - pos_inv = create_pos_invoice(qty=1, do_not_submit=1) - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.items[0].discount_percentage, 10) - # rate shouldn't change - self.assertEquals(pos_inv.items[0].rate, 405) - pos_inv.ignore_pricing_rule = 1 - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.ignore_pricing_rule, 1) - # rate should change since pricing rules are ignored - self.assertEquals(pos_inv.items[0].rate, 300) + try: + pos_inv = create_pos_invoice(qty=1, do_not_submit=1) + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].discount_percentage, 10) + # rate shouldn't change + self.assertEquals(pos_inv.items[0].rate, 405) - item_price.delete() - pos_inv.delete() - pr.delete() + pos_inv.ignore_pricing_rule = 1 + pos_inv.save() + self.assertEquals(pos_inv.ignore_pricing_rule, 1) + # rate should reset since pricing rules are ignored + self.assertEquals(pos_inv.items[0].rate, 450) + + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].rate, 300) + + finally: + item_price.delete() + pos_inv.delete() + pr.delete() def create_pos_invoice(**args): diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 65ded03673..933fda8a0a 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -250,13 +250,16 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa "parent": args.parent, "parenttype": args.parenttype, "child_docname": args.get('child_docname'), - "price_list_rate": args.get('price_list_rate') }) if args.ignore_pricing_rule or not args.item_code: if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details update_args_for_pricing_rule(args) @@ -309,8 +312,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa if not doc: return item_details elif args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details @@ -391,7 +398,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args): item_details[field] += (pricing_rule.get(field, 0) if pricing_rule else args.get(field, 0)) -def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): +def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None): from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules, get_pricing_rule_items, @@ -404,7 +411,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): if pricing_rule.rate_or_discount == 'Discount Percentage': item_details.discount_percentage = 0.0 item_details.discount_amount = 0.0 - item_details.rate = item_details.get('price_list_rate', 0) + item_details.rate = rate or 0.0 if pricing_rule.rate_or_discount == 'Discount Amount': item_details.discount_amount = 0.0 @@ -435,9 +442,12 @@ def remove_pricing_rules(item_list): out = [] for item in item_list: item = frappe._dict(item) - if item.get('pricing_rules'): - out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), - item, item.item_code)) + if item.get("pricing_rules"): + out.append( + remove_pricing_rule_for_item( + item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate") + ) + ) return out From eaccef6116f051bfa8c65934c1b45767e7465aaa Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 8 Feb 2022 17:55:38 +0530 Subject: [PATCH 066/112] fix: Initialise pending qty as planned qty for independent item rows in Prod Plan - Rows that are not fetched from MR or SO, had pending qty 0 throughout - Initialise pending qty on save only. - After submit this field will be updated by work order/stock entry - Bring functions in `validate()` closer to the top --- .../production_plan/production_plan.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index a6d4dfc16e..839547d0fe 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -28,9 +28,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults class ProductionPlan(Document): def validate(self): + self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() self.set_status() + def set_pending_qty_in_row_without_reference(self): + "Set Pending Qty in independent rows (not from SO or MR)." + if self.docstatus > 0: # set only to initialise value before submit + return + + for item in self.po_items: + if not item.get("sales_order") or not item.get("material_request"): + item.pending_qty = item.planned_qty + + def calculate_total_planned_qty(self): + self.total_planned_qty = 0 + for d in self.po_items: + self.total_planned_qty += flt(d.planned_qty) + def validate_data(self): for d in self.get('po_items'): if not d.bom_no: @@ -263,11 +278,6 @@ class ProductionPlan(Document): 'qty': so_detail['qty'] }) - def calculate_total_planned_qty(self): - self.total_planned_qty = 0 - for d in self.po_items: - self.total_planned_qty += flt(d.planned_qty) - def calculate_total_produced_qty(self): self.total_produced_qty = 0 for d in self.po_items: From 6a8b7eeffecba15e8a664449b6d92f5a8aa8d2cf Mon Sep 17 00:00:00 2001 From: aaronmenezes Date: Tue, 8 Feb 2022 19:25:49 +0530 Subject: [PATCH 067/112] fix: Reserved for Production calculation considered closed work orders --- .../doctype/work_order/test_work_order.py | 13 +++++++++++++ erpnext/stock/doctype/bin/bin.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index a399edda70..a38e04a5c7 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -201,6 +201,19 @@ class TestWorkOrder(ERPNextTestCase): self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), cint(bin1_on_start_production.reserved_qty_for_production)) + def test_reserved_qty_for_production(self): + self.bin1_at_start = get_bin(self.item, self.warehouse) + self.bin1_at_start.update_reserved_qty_for_production() + self.test_reserved_qty_for_production_submit() + self.test_reserved_qty_for_production_cancel() + self.test_close_work_order() + self.wo_order = make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + self.bin1_on_submit = get_bin(self.item, self.warehouse) + bin1_on_end_production = get_bin(self.item, self.warehouse) + self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), + cint(self.bin1_at_start.reserved_qty_for_production) + 2) + def test_backflush_qty_for_overpduction_manufacture(self): cancel_stock_entry = [] allow_overproduction("overproduction_percentage_for_work_order", 30) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index c34e9d05ce..6bf94339ed 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -52,7 +52,7 @@ class Bin(Document): & (wo_item.parent == wo.name) & (wo.docstatus == 1) & (wo_item.source_warehouse == self.warehouse) - & (wo.status.notin(["Stopped", "Completed"])) + & (wo.status.notin(["Stopped", "Completed", "Closed"])) & ((wo_item.required_qty > wo_item.transferred_qty) | (wo_item.required_qty > wo_item.consumed_qty)) ) From ab36b27a94f5f88a71358292ef7b76103c7080b7 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 9 Feb 2022 10:10:17 +0530 Subject: [PATCH 068/112] fix: ignore pricing rule in all transactions --- erpnext/controllers/accounts_controller.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index d86b6c7ea4..994b903b32 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -407,11 +407,19 @@ class AccountsController(TransactionBase): if item_qty != len(get_serial_nos(item.get('serial_no'))): item.set(fieldname, value) - elif ret.get("pricing_rule_removed") and value is not None \ - and fieldname in [ - 'discount_percentage', 'discount_amount', 'rate', - 'margin_rate_or_amount', 'margin_type', 'remove_free_item' - ]: + elif ( + ret.get("pricing_rule_removed") + and value is not None + and fieldname + in [ + "discount_percentage", + "discount_amount", + "rate", + "margin_rate_or_amount", + "margin_type", + "remove_free_item", + ] + ): # reset pricing rule fields if pricing_rule_removed item.set(fieldname, value) From d2cc5f2482727ee82ef914ea768311a5c1f94996 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 9 Feb 2022 11:19:01 +0530 Subject: [PATCH 069/112] test: remove dependency on other tests --- .../doctype/work_order/test_work_order.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index a38e04a5c7..1145a586a5 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -201,18 +201,20 @@ class TestWorkOrder(ERPNextTestCase): self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), cint(bin1_on_start_production.reserved_qty_for_production)) - def test_reserved_qty_for_production(self): - self.bin1_at_start = get_bin(self.item, self.warehouse) - self.bin1_at_start.update_reserved_qty_for_production() - self.test_reserved_qty_for_production_submit() - self.test_reserved_qty_for_production_cancel() - self.test_close_work_order() - self.wo_order = make_wo_order_test_record(item="_Test FG Item", qty=2, + def test_reserved_qty_for_production_closed(self): + + wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2, source_warehouse=self.warehouse) - self.bin1_on_submit = get_bin(self.item, self.warehouse) - bin1_on_end_production = get_bin(self.item, self.warehouse) - self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), - cint(self.bin1_at_start.reserved_qty_for_production) + 2) + item = wo1.required_items[0].item_code + bin_before = get_bin(item, self.warehouse) + bin_before.update_reserved_qty_for_production() + + make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + close_work_order(wo1.name, "Closed") + + bin_after = get_bin(item, self.warehouse) + self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production) def test_backflush_qty_for_overpduction_manufacture(self): cancel_stock_entry = [] From a8bf3a3f0d21ba8b841b69b2185c9d2bd46cd3f2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 9 Feb 2022 11:26:03 +0530 Subject: [PATCH 070/112] refactor: move reserve quantity computation to work order --- .../doctype/work_order/work_order.py | 26 +++++++++++++++++++ erpnext/stock/doctype/bin/bin.py | 23 ++-------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a86edfa45f..7315249512 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum from frappe.utils import ( cint, date_diff, @@ -1175,3 +1177,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): doc.set_item_locations() return doc + +def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float: + """Get total reserved quantity for any item in specified warehouse""" + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + return ( + frappe.qb + .from_(wo) + .from_(wo_item) + .select(Sum(Case() + .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) + .else_(wo_item.required_qty - wo_item.consumed_qty)) + ) + .where( + (wo_item.item_code == item_code) + & (wo_item.parent == wo.name) + & (wo.docstatus == 1) + & (wo_item.source_warehouse == warehouse) + & (wo.status.notin(["Stopped", "Completed", "Closed"])) + & ((wo_item.required_qty > wo_item.transferred_qty) + | (wo_item.required_qty > wo_item.consumed_qty)) + ) + ).run()[0][0] or 0.0 diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 6bf94339ed..d2bae65239 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -35,28 +35,9 @@ class Bin(Document): def update_reserved_qty_for_production(self): '''Update qty reserved for production from Production Item tables in open work orders''' + from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production - wo = frappe.qb.DocType("Work Order") - wo_item = frappe.qb.DocType("Work Order Item") - - self.reserved_qty_for_production = ( - frappe.qb - .from_(wo) - .from_(wo_item) - .select(Sum(Case() - .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) - .else_(wo_item.required_qty - wo_item.consumed_qty)) - ) - .where( - (wo_item.item_code == self.item_code) - & (wo_item.parent == wo.name) - & (wo.docstatus == 1) - & (wo_item.source_warehouse == self.warehouse) - & (wo.status.notin(["Stopped", "Completed", "Closed"])) - & ((wo_item.required_qty > wo_item.transferred_qty) - | (wo_item.required_qty > wo_item.consumed_qty)) - ) - ).run()[0][0] or 0.0 + self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse) self.set_projected_qty() From 89fa0bb73f1a192c2bfe8bc0a87956cb12ff6352 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 9 Feb 2022 11:28:14 +0530 Subject: [PATCH 071/112] fix: consider based on DOJ config while calculating leaves for passed months --- .../leave_policy_assignment.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 41a9558deb..6168db8502 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -119,14 +119,15 @@ class LeavePolicyAssignment(Document): from_date_year = get_datetime(from_date).year months_passed = 0 + based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining - if current_year == from_date_year and current_month > from_date_month: + if current_year == from_date_year and current_month >= from_date_month: months_passed = current_month - from_date_month - months_passed = add_current_month_if_applicable(months_passed) + months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) elif current_year > from_date_year: months_passed = (12 - from_date_month) + current_month - months_passed = add_current_month_if_applicable(months_passed) + months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) if months_passed > 0: monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, @@ -138,13 +139,20 @@ class LeavePolicyAssignment(Document): return new_leaves_allocated -def add_current_month_if_applicable(months_passed): +def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj): date = getdate(frappe.flags.current_date) or getdate() - last_day_of_month = get_last_day(date) - # if its the last day of the month, then that month should also be considered - if last_day_of_month == date: - months_passed += 1 + if based_on_doj: + # if leave type allocation is based on DOJ, + # and the date of assignment creation is same as DOJ, + # then the month should be considered + if date == date_of_joining: + months_passed += 1 + else: + last_day_of_month = get_last_day(date) + # if its the last day of the month, then that month should be considered + if last_day_of_month == date: + months_passed += 1 return months_passed @@ -183,7 +191,7 @@ def create_assignment_for_multiple_employees(employees, data): def get_leave_type_details(): leave_type_details = frappe._dict() leave_types = frappe.get_all("Leave Type", - fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", + fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining", "is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"]) for d in leave_types: leave_type_details.setdefault(d.name, d) From 89959976bdc6110630d523b92c7c9321b4aace05 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 9 Feb 2022 11:38:52 +0530 Subject: [PATCH 072/112] fix: patch existing bins --- erpnext/patches.txt | 1 + .../v13_0/update_reserved_qty_closed_wo.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 erpnext/patches/v13_0/update_reserved_qty_closed_wo.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index feafecbc04..d300340671 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -350,3 +350,4 @@ erpnext.patches.v14_0.migrate_cost_center_allocations erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.update_disbursement_account +erpnext.patches.v13_0.update_reserved_qty_closed_wo diff --git a/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py new file mode 100644 index 0000000000..00926b0924 --- /dev/null +++ b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py @@ -0,0 +1,28 @@ +import frappe + +from erpnext.stock.utils import get_bin + + +def execute(): + + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + incorrect_item_wh = ( + frappe.qb + .from_(wo) + .join(wo_item).on(wo.name == wo_item.parent) + .select(wo_item.item_code, wo.source_warehouse).distinct() + .where( + (wo.status == "Closed") + & (wo.docstatus == 1) + & (wo.source_warehouse.notnull()) + ) + ).run() + + for item_code, warehouse in incorrect_item_wh: + if not (item_code and warehouse): + continue + + bin = get_bin(item_code, warehouse) + bin.update_reserved_qty_for_production() From 75256863c6e3ed917d3ff00a9435da9fa7115cbb Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 9 Feb 2022 10:05:06 +0530 Subject: [PATCH 073/112] fix(test): do not enable negative stock --- .../test_pos_invoice_merge_log.py | 22 ++++++++++++++----- erpnext/controllers/taxes_and_totals.py | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index fc14161456..5930aa097f 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( consolidate_pos_invoices, ) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry class TestPOSInvoiceMergeLog(unittest.TestCase): @@ -156,11 +157,17 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): ''' Test round off error in consolidated invoice creation if POS Invoice has inclusive tax ''' + frappe.db.sql("delete from `tabPOS Invoice`") - allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + init_user_and_profile() inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True) @@ -206,17 +213,21 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', allow_negative_stock) def test_consolidation_round_off_error_2(self): ''' Test the same case as above but with an Unpaid POS Invoice ''' frappe.db.sql("delete from `tabPOS Invoice`") - allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + init_user_and_profile() inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True) @@ -269,4 +280,3 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") - frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', allow_negative_stock) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index de1099ee28..2776628227 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -654,7 +654,7 @@ class calculate_taxes_and_totals(object): base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total if self.doc.doctype == "Sales Invoice" \ - and self.doc.paid_amount > grand_total and not self.doc.is_return \ + and self.doc.paid_amount > grand_total and not self.doc.is_return \ and any(d.type == "Cash" for d in self.doc.payments): self.doc.change_amount = flt(self.doc.paid_amount - grand_total + From 4bb557dbd84b109b83de12b2e77a60d953c292ea Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 9 Feb 2022 12:06:59 +0530 Subject: [PATCH 074/112] fix: flaky point of sale test --- erpnext/tests/test_point_of_sale.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py index df2dc8b99a..3299c8885f 100644 --- a/erpnext/tests/test_point_of_sale.py +++ b/erpnext/tests/test_point_of_sale.py @@ -1,15 +1,25 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt +import unittest + +import frappe from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.selling.page.point_of_sale.point_of_sale import get_items from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.tests.utils import ERPNextTestCase -class TestPointOfSale(ERPNextTestCase): +class TestPointOfSale(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + frappe.db.savepoint('before_test_point_of_sale') + + @classmethod + def tearDownClass(cls) -> None: + frappe.db.rollback(save_point='before_test_point_of_sale') + def test_item_search(self): """ Test Stock and Service Item Search. From 0ebd32dc630daf03dc77f81a93944a1919f0c016 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 9 Feb 2022 16:08:28 +0530 Subject: [PATCH 075/112] fix: cancelling of consolidated sales invoice that doesn't have closing entry --- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 754ca81424..b894f90c7e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -285,7 +285,7 @@ class SalesInvoice(SellingController): filters={ invoice_or_credit_note: self.name }, pluck="pos_closing_entry" ) - if pos_closing_entry: + if pos_closing_entry and pos_closing_entry[0]: msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( frappe.bold("Consolidated Sales Invoice"), get_link_to_form("POS Closing Entry", pos_closing_entry[0]) From b68a99675d12a1ffbda538ee07a2020ba66fb3cc Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 9 Feb 2022 16:56:24 +0530 Subject: [PATCH 076/112] fix: allow `regional_overrides` hook to be set in subsequent apps --- erpnext/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 0b4696c803..bef6661254 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -2,8 +2,6 @@ import inspect import frappe -from erpnext.hooks import regional_overrides - __version__ = '14.0.0-dev' def get_default_company(user=None): @@ -121,14 +119,17 @@ def allow_regional(fn): @erpnext.allow_regional def myfunction(): pass''' + def caller(*args, **kwargs): - region = get_region() - fn_name = inspect.getmodule(fn).__name__ + '.' + fn.__name__ - if region in regional_overrides and fn_name in regional_overrides[region]: - return frappe.get_attr(regional_overrides[region][fn_name])(*args, **kwargs) - else: + overrides = frappe.get_hooks("regional_overrides", {}).get(get_region()) + function_path = f"{inspect.getmodule(fn).__name__}.{fn.__name__}" + + if not overrides or function_path not in overrides: return fn(*args, **kwargs) + # Priority given to last installed app + return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs) + return caller def get_last_membership(member): From 6e679a5ad2f82f6c97deb4446590abe0d5c3ab46 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 8 Feb 2022 10:59:04 +0530 Subject: [PATCH 077/112] fix(India): Report GSTR-1 minor fixes --- erpnext/regional/report/gstr_1/gstr_1.py | 26 ++++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index e50ff18032..77542608e4 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -28,7 +28,7 @@ class Gstr1Report(object): posting_date, base_grand_total, base_rounded_total, - COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin, + NULLIF(billing_address_gstin, '') as billing_address_gstin, place_of_supply, ecommerce_gstin, reverse_charge, @@ -259,7 +259,7 @@ class Gstr1Report(object): if self.filters.get("type_of_business") == "B2B": - conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1" + conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1" if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"): b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit') @@ -383,7 +383,7 @@ class Gstr1Report(object): for invoice, items in self.invoice_items.items(): if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \ and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \ - and self.invoices.get(invoice, {}).get('gst_category') == "Overseas": + and self.invoices.get(invoice, {}).get('gst_category') in ["Overseas", "SEZ"]: self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) def get_columns(self): @@ -409,7 +409,7 @@ class Gstr1Report(object): if self.filters.get("type_of_business") == "B2B": self.invoice_columns = [ { - "fieldname": "customer_gstin", + "fieldname": "billing_address_gstin", "label": "GSTIN/UIN of Recipient", "fieldtype": "Data", "width": 150 @@ -516,7 +516,7 @@ class Gstr1Report(object): elif self.filters.get("type_of_business") == "CDNR-REG": self.invoice_columns = [ { - "fieldname": "customer_gstin", + "fieldname": "billing_address_gstin", "label": "GSTIN/UIN of Recipient", "fieldtype": "Data", "width": 150 @@ -817,7 +817,7 @@ def get_json(filters, report_name, data): res = {} if filters["type_of_business"] == "B2B": for item in report_data[:-1]: - res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) + res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) out = get_b2b_json(res, gstin) gst_json["b2b"] = out @@ -841,7 +841,7 @@ def get_json(filters, report_name, data): gst_json["exp"] = out elif filters["type_of_business"] == "CDNR-REG": for item in report_data[:-1]: - res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) + res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) out = get_cdnr_reg_json(res, gstin) gst_json["cdnr"] = out @@ -875,7 +875,7 @@ def get_json(filters, report_name, data): } def get_b2b_json(res, gstin): - inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, [] + out = [] for gst_in in res: b2b_item, inv = {"ctin": gst_in, "inv": []}, [] if not gst_in: continue @@ -889,7 +889,7 @@ def get_b2b_json(res, gstin): inv_item = get_basic_invoice_detail(invoice[0]) inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]) inv_item["rchrg"] = invoice[0]["reverse_charge"] - inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"") + inv_item["inv_typ"] = get_invoice_type_registered(invoice[0]) if inv_item["pos"]=="00": continue inv_item["itms"] = [] @@ -1044,7 +1044,7 @@ def get_cdnr_reg_json(res, gstin): "ntty": invoice[0]["document_type"], "pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]), "rchrg": invoice[0]["reverse_charge"], - "inv_typ": get_invoice_type_for_cdnr(invoice[0]) + "inv_typ": get_invoice_type_registered(invoice[0]) } inv_item["itms"] = [] @@ -1110,7 +1110,7 @@ def get_exempted_json(data): return out -def get_invoice_type_for_cdnr(row): +def get_invoice_type_registered(row): if row.get('gst_category') == 'SEZ': if row.get('export_type') == 'WPAY': invoice_type = 'SEWP' @@ -1118,7 +1118,7 @@ def get_invoice_type_for_cdnr(row): invoice_type = 'SEWOP' elif row.get('gst_category') == 'Deemed Export': invoice_type = 'DE' - elif row.get('gst_category') == 'Registered Regular': + elif row.get('gst_category') in ['Registered Regular', 'Registered Composition']: invoice_type = 'R' return invoice_type @@ -1154,7 +1154,7 @@ def get_rate_and_tax_details(row, gstin): # calculate tax amount added tax = flt((row["taxable_value"]*rate)/100.0, 2) frappe.errprint([tax, tax/2]) - if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]: + if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]: itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)}) else: itm_det.update({"iamt": tax}) From 2bc157a95cff5d13f492fddf7c177b3e67fe62a8 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 9 Feb 2022 17:43:44 +0530 Subject: [PATCH 078/112] fix: cleaner implementation for `get_invoice_type` --- erpnext/regional/report/gstr_1/gstr_1.py | 42 ++++++++++-------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 77542608e4..ce2ffb4010 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -383,7 +383,7 @@ class Gstr1Report(object): for invoice, items in self.invoice_items.items(): if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \ and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \ - and self.invoices.get(invoice, {}).get('gst_category') in ["Overseas", "SEZ"]: + and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"): self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) def get_columns(self): @@ -889,7 +889,7 @@ def get_b2b_json(res, gstin): inv_item = get_basic_invoice_detail(invoice[0]) inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]) inv_item["rchrg"] = invoice[0]["reverse_charge"] - inv_item["inv_typ"] = get_invoice_type_registered(invoice[0]) + inv_item["inv_typ"] = get_invoice_type(invoice[0]) if inv_item["pos"]=="00": continue inv_item["itms"] = [] @@ -1044,7 +1044,7 @@ def get_cdnr_reg_json(res, gstin): "ntty": invoice[0]["document_type"], "pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]), "rchrg": invoice[0]["reverse_charge"], - "inv_typ": get_invoice_type_registered(invoice[0]) + "inv_typ": get_invoice_type(invoice[0]) } inv_item["itms"] = [] @@ -1069,7 +1069,7 @@ def get_cdnr_unreg_json(res, gstin): "val": abs(flt(items[0]["invoice_value"])), "ntty": items[0]["document_type"], "pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]), - "typ": get_invoice_type_for_cdnrur(items[0]) + "typ": get_invoice_type(items[0]) } inv_item["itms"] = [] @@ -1110,29 +1110,21 @@ def get_exempted_json(data): return out -def get_invoice_type_registered(row): - if row.get('gst_category') == 'SEZ': - if row.get('export_type') == 'WPAY': - invoice_type = 'SEWP' - else: - invoice_type = 'SEWOP' - elif row.get('gst_category') == 'Deemed Export': - invoice_type = 'DE' - elif row.get('gst_category') in ['Registered Regular', 'Registered Composition']: - invoice_type = 'R' +def get_invoice_type(row): + gst_category = row.get('gst_category') - return invoice_type + if gst_category == 'SEZ': + return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP' -def get_invoice_type_for_cdnrur(row): - if row.get('gst_category') == 'Overseas': - if row.get('export_type') == 'WPAY': - invoice_type = 'EXPWP' - else: - invoice_type = 'EXPWOP' - elif row.get('gst_category') == 'Unregistered': - invoice_type = 'B2CL' + if gst_category == 'Overseas': + return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP' - return invoice_type + return ({ + 'Deemed Export': 'DE', + 'Registered Regular': 'R', + 'Registered Composition': 'R', + 'Unregistered': 'B2CL' + }).get(gst_category) def get_basic_invoice_detail(row): return { @@ -1199,4 +1191,4 @@ def is_inter_state(invoice_detail): if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]: return True else: - return False \ No newline at end of file + return False From 5811d9e318de46095f85fb183583e61d14aff7ef Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 9 Feb 2022 17:53:33 +0100 Subject: [PATCH 079/112] fix: encode filters for URI --- erpnext/regional/report/datev/datev.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js index 4124e3df19..03c729e6df 100644 --- a/erpnext/regional/report/datev/datev.js +++ b/erpnext/regional/report/datev/datev.js @@ -40,7 +40,11 @@ frappe.query_reports["DATEV"] = { }); query_report.page.add_menu_item(__("Download DATEV File"), () => { - const filters = JSON.stringify(query_report.get_values()); + const filters = encodeURIComponent( + JSON.stringify( + query_report.get_values() + ) + ); window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`); }); From c371b52d279c02af0632c9e783e45c13e30ebaac Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 10 Feb 2022 10:20:14 +0530 Subject: [PATCH 080/112] fix: restrict filetypes to csv for rename tool --- erpnext/utilities/doctype/rename_tool/rename_tool.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js index 7823055e52..5553e44ef8 100644 --- a/erpnext/utilities/doctype/rename_tool/rename_tool.js +++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js @@ -13,6 +13,12 @@ frappe.ui.form.on("Rename Tool", { }, refresh: function(frm) { frm.disable_save(); + + frm.get_field("file_to_rename").df.options = { + restrictions: { + allowed_file_types: [".csv"], + }, + }; if (!frm.doc.file_to_rename) { frm.get_field("rename_log").$wrapper.html(""); } From eec2f87088e630a7ef2a918d64dd3cf2b78787d3 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 10 Feb 2022 12:30:41 +0530 Subject: [PATCH 081/112] fix: time out error while making work orders from prodcution plan --- .../production_plan/production_plan.py | 79 +++++++++++-------- .../doctype/work_order/work_order.py | 5 +- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 4290ca3e4c..55054bb997 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -341,6 +341,7 @@ class ProductionPlan(Document): def get_production_items(self): item_dict = {} + for d in self.po_items: item_details = { "production_item" : d.item_code, @@ -357,12 +358,12 @@ class ProductionPlan(Document): "production_plan" : self.name, "production_plan_item" : d.name, "product_bundle_item" : d.product_bundle_item, - "planned_start_date" : d.planned_start_date + "planned_start_date" : d.planned_start_date, + "project" : self.project } - item_details.update({ - "project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project") - }) + if not item_details['project'] and d.sales_order: + item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project") if self.get_items_from == "Material Request": item_details.update({ @@ -380,39 +381,59 @@ class ProductionPlan(Document): @frappe.whitelist() def make_work_order(self): + from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse + wo_list, po_list = [], [] subcontracted_po = {} + default_warehouses = get_default_warehouse() - self.validate_data() - self.make_work_order_for_finished_goods(wo_list) - self.make_work_order_for_subassembly_items(wo_list, subcontracted_po) + self.make_work_order_for_finished_goods(wo_list, default_warehouses) + self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses) self.make_subcontracted_purchase_order(subcontracted_po, po_list) self.show_list_created_message('Work Order', wo_list) self.show_list_created_message('Purchase Order', po_list) - def make_work_order_for_finished_goods(self, wo_list): + def make_work_order_for_finished_goods(self, wo_list, default_warehouses): items_data = self.get_production_items() for key, item in items_data.items(): if self.sub_assembly_items: item['use_multi_level_bom'] = 0 + set_default_warehouses(item, default_warehouses) work_order = self.create_work_order(item) if work_order: wo_list.append(work_order) - def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po): + def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses): for row in self.sub_assembly_items: if row.type_of_manufacturing == 'Subcontract': subcontracted_po.setdefault(row.supplier, []).append(row) continue - args = {} - self.prepare_args_for_sub_assembly_items(row, args) - work_order = self.create_work_order(args) + work_order_data = { + 'wip_warehouse': default_warehouses.get('wip_warehouse'), + 'fg_warehouse': default_warehouses.get('fg_warehouse') + } + + self.prepare_data_for_sub_assembly_items(row, work_order_data) + work_order = self.create_work_order(work_order_data) if work_order: wo_list.append(work_order) + def prepare_data_for_sub_assembly_items(self, row, wo_data): + for field in ["production_item", "item_name", "qty", "fg_warehouse", + "description", "bom_no", "stock_uom", "bom_level", + "production_plan_item", "schedule_date"]: + if row.get(field): + wo_data[field] = row.get(field) + + wo_data.update({ + "use_multi_level_bom": 0, + "production_plan": self.name, + "production_plan_sub_assembly_item": row.name + }) + def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): if not subcontracted_po: return @@ -423,7 +444,7 @@ class ProductionPlan(Document): po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() po.is_subcontracted = 'Yes' for row in po_list: - args = { + po_data = { 'item_code': row.production_item, 'warehouse': row.fg_warehouse, 'production_plan_sub_assembly_item': row.name, @@ -433,9 +454,9 @@ class ProductionPlan(Document): for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name', 'description', 'production_plan_item']: - args[field] = row.get(field) + po_data[field] = row.get(field) - po.append('items', args) + po.append('items', po_data) po.set_missing_values() po.flags.ignore_mandatory = True @@ -452,24 +473,9 @@ class ProductionPlan(Document): doc_list = [get_link_to_form(doctype, p) for p in doc_list] msgprint(_("{0} created").format(comma_and(doc_list))) - def prepare_args_for_sub_assembly_items(self, row, args): - for field in ["production_item", "item_name", "qty", "fg_warehouse", - "description", "bom_no", "stock_uom", "bom_level", - "production_plan_item", "schedule_date"]: - args[field] = row.get(field) - - args.update({ - "use_multi_level_bom": 0, - "production_plan": self.name, - "production_plan_sub_assembly_item": row.name - }) - def create_work_order(self, item): - from erpnext.manufacturing.doctype.work_order.work_order import ( - OverProductionError, - get_default_warehouse, - ) - warehouse = get_default_warehouse() + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + wo = frappe.new_doc("Work Order") wo.update(item) wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date') @@ -478,11 +484,11 @@ class ProductionPlan(Document): wo.fg_warehouse = item.get("warehouse") wo.set_work_order_operations() + wo.set_required_items() - if not wo.fg_warehouse: - wo.fg_warehouse = warehouse.get('fg_warehouse') try: wo.flags.ignore_mandatory = True + wo.flags.ignore_validate = True wo.insert() return wo.name except OverProductionError: @@ -1023,3 +1029,8 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): if d.value: get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) + +def set_default_warehouses(row, default_warehouses): + for field in ['wip_warehouse', 'fg_warehouse']: + if not row.get(field): + row[field] = default_warehouses.get(field) \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a86edfa45f..7471587e07 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -74,7 +74,6 @@ class WorkOrder(Document): self.set_required_items(reset_only_qty = len(self.get("required_items"))) - def validate_sales_order(self): if self.sales_order: self.check_sales_order_on_hold_or_close() @@ -544,7 +543,7 @@ class WorkOrder(Document): if node.is_bom: operations.extend(_get_operations(node.name, qty=node.exploded_qty)) - bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") + bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity") operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty)) for correct_index, operation in enumerate(operations, start=1): @@ -625,7 +624,7 @@ class WorkOrder(Document): frappe.delete_doc("Job Card", d.name) def validate_production_item(self): - if frappe.db.get_value("Item", self.production_item, "has_variants"): + if frappe.get_cached_value("Item", self.production_item, "has_variants"): frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError) if self.production_item: From 86ca41b14af45f44ec63a27ed10580b161a33b4c Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 10 Feb 2022 20:14:28 +0530 Subject: [PATCH 082/112] test: Production Plan Pending Qty impact tests - Two tests to check impact on pending qty: From SO and independent Prod Plan - Added docstring to each test case for brief summary - Changed helper function args to fallback to 0 instead of 1 if no arg is passed - Removed unnecessary `get_doc()` - Made helper function actions optional depending on args passed --- .../production_plan/production_plan.py | 2 +- .../production_plan/test_production_plan.py | 253 ++++++++++++++---- .../doctype/work_order/work_order.py | 2 +- 3 files changed, 209 insertions(+), 48 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 839547d0fe..10bd23d528 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -285,7 +285,7 @@ class ProductionPlan(Document): self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False) - def update_produced_qty(self, produced_qty, production_plan_item): + def update_produced_pending_qty(self, produced_qty, production_plan_item): for data in self.po_items: if data.name == production_plan_item: data.produced_qty = produced_qty diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 276e70859e..3aa5c9f008 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -11,6 +11,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) @@ -36,15 +37,21 @@ class TestProductionPlan(ERPNextTestCase): if not frappe.db.get_value('BOM', {'item': item}): make_bom(item = item, raw_materials = raw_materials) - def test_production_plan(self): + def test_production_plan_mr_creation(self): + "Test if MRs are created for unavailable raw materials." pln = create_production_plan(item_code='Test Production Item 1') self.assertTrue(len(pln.mr_items), 2) - pln.make_material_request() - pln = frappe.get_doc('Production Plan', pln.name) + pln.make_material_request() + pln.reload() self.assertTrue(pln.status, 'Material Requested') - material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'], - filters = {'production_plan': pln.name}, as_list=1) + + material_requests = frappe.get_all( + 'Material Request Item', + fields = ['distinct parent'], + filters = {'production_plan': pln.name}, + as_list=1 + ) self.assertTrue(len(material_requests), 2) @@ -66,27 +73,42 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_start_date(self): + "Test if Work Order has same Planned Start Date as Prod Plan." planned_date = add_to_date(date=None, days=3) - plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date) + plan = create_production_plan( + item_code='Test Production Item 1', + planned_start_date=planned_date + ) plan.make_work_order() - work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'], - filters = {'production_plan': plan.name}) + work_orders = frappe.get_all( + 'Work Order', + fields = ['name', 'planned_start_date'], + filters = {'production_plan': plan.name} + ) self.assertEqual(work_orders[0].planned_start_date, planned_date) for wo in work_orders: frappe.delete_doc('Work Order', wo.name) - frappe.get_doc('Production Plan', plan.name).cancel() + plan.reload() + plan.cancel() def test_production_plan_for_existing_ordered_qty(self): + """ + - Enable 'ignore_existing_ordered_qty'. + - Test if MR Planning table pulls Raw Material Qty even if it is in stock. + """ sr1 = create_stock_reconciliation(item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=1, rate=110) sr2 = create_stock_reconciliation(item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=1, rate=120) - pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0) + pln = create_production_plan( + item_code='Test Production Item 1', + ignore_existing_ordered_qty=1 + ) self.assertTrue(len(pln.mr_items), 1) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) @@ -95,23 +117,39 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_with_non_stock_item(self): - pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0) + "Test if MR Planning table includes Non Stock RM." + pln = create_production_plan( + item_code='Test Production Item 1', + include_non_stock_items=1 + ) self.assertTrue(len(pln.mr_items), 3) pln.cancel() def test_production_plan_without_multi_level(self): - pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0) + "Test MR Planning table for non exploded BOM." + pln = create_production_plan( + item_code='Test Production Item 1', + use_multi_level_bom=0 + ) self.assertTrue(len(pln.mr_items), 2) pln.cancel() def test_production_plan_without_multi_level_for_existing_ordered_qty(self): + """ + - Disable 'ignore_existing_ordered_qty'. + - Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for + non exploded BOM. + """ sr1 = create_stock_reconciliation(item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=1, rate=130) sr2 = create_stock_reconciliation(item_code="Subassembly Item 1", target="_Test Warehouse - _TC", qty=1, rate=140) - pln = create_production_plan(item_code='Test Production Item 1', - use_multi_level_bom=0, ignore_existing_ordered_qty=0) + pln = create_production_plan( + item_code='Test Production Item 1', + use_multi_level_bom=0, + ignore_existing_ordered_qty=0 + ) self.assertTrue(len(pln.mr_items), 0) sr1.cancel() @@ -119,6 +157,7 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_sales_orders(self): + "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." item = 'Test Production Item 1' so = make_sales_order(item_code=item, qty=1) sales_order = so.name @@ -166,24 +205,25 @@ class TestProductionPlan(ERPNextTestCase): self.assertEqual(sales_orders, []) def test_production_plan_combine_items(self): + "Test combining FG items in Production Plan." item = 'Test Production Item 1' - so = make_sales_order(item_code=item, qty=1) + so1 = make_sales_order(item_code=item, qty=1) pln = frappe.new_doc('Production Plan') - pln.company = so.company + pln.company = so1.company pln.get_items_from = 'Sales Order' pln.append('sales_orders', { - 'sales_order': so.name, - 'sales_order_date': so.transaction_date, - 'customer': so.customer, - 'grand_total': so.grand_total + 'sales_order': so1.name, + 'sales_order_date': so1.transaction_date, + 'customer': so1.customer, + 'grand_total': so1.grand_total }) - so = make_sales_order(item_code=item, qty=2) + so2 = make_sales_order(item_code=item, qty=2) pln.append('sales_orders', { - 'sales_order': so.name, - 'sales_order_date': so.transaction_date, - 'customer': so.customer, - 'grand_total': so.grand_total + 'sales_order': so2.name, + 'sales_order_date': so2.transaction_date, + 'customer': so2.customer, + 'grand_total': so2.grand_total }) pln.combine_items = 1 pln.get_items() @@ -214,28 +254,37 @@ class TestProductionPlan(ERPNextTestCase): so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty') self.assertEqual(so_wo_qty, 0.0) - latest_plan = frappe.get_doc('Production Plan', pln.name) - latest_plan.cancel() + pln.reload() + pln.cancel() def test_pp_to_mr_customer_provided(self): - #Material Request from Production Plan for Customer Provided + " Test Material Request from Production Plan for Customer Provided Item." create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) create_item('Production Item CUST') + for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items(): if not frappe.db.get_value('BOM', {'item': item}): make_bom(item = item, raw_materials = raw_materials) production_plan = create_production_plan(item_code = 'Production Item CUST') production_plan.make_material_request() - material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent') + + material_request = frappe.db.get_value( + 'Material Request Item', + {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, + 'parent' + ) mr = frappe.get_doc('Material Request', material_request) + self.assertTrue(mr.material_request_type, 'Customer Provided') self.assertTrue(mr.customer, '_Test Customer') def test_production_plan_with_multi_level_bom(self): - #|Item Code | Qty | - #|Test BOM 1 | 1 | - #| Test BOM 2 | 2 | - #| Test BOM 3 | 3 | + """ + Item Code | Qty | + |Test BOM 1 | 1 | + |Test BOM 2 | 2 | + |Test BOM 3 | 3 | + """ for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]: create_item(item_code, is_stock_item=1) @@ -264,15 +313,18 @@ class TestProductionPlan(ERPNextTestCase): pln.make_work_order() #last level sub-assembly work order produce qty - to_produce_qty = frappe.db.get_value("Work Order", - {"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty") + to_produce_qty = frappe.db.get_value( + "Work Order", + {"production_plan": pln.name, "production_item": "Test BOM 3"}, + "qty" + ) self.assertEqual(to_produce_qty, 18.0) pln.cancel() frappe.delete_doc("Production Plan", pln.name) def test_get_warehouse_list_group(self): - """Check if required warehouses are returned""" + "Check if required child warehouses are returned." warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]' warehouses = set(get_warehouse_list(warehouse_json)) @@ -284,6 +336,7 @@ class TestProductionPlan(ERPNextTestCase): msg=f"Following warehouses were expected {', '.join(missing_warehouse)}") def test_get_warehouse_list_single(self): + "Check if same warehouse is returned in absence of child warehouses." warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]' warehouses = set(get_warehouse_list(warehouse_json)) @@ -292,6 +345,7 @@ class TestProductionPlan(ERPNextTestCase): self.assertEqual(warehouses, expected_warehouses) def test_get_sales_order_with_variant(self): + "Check if Template BOM is fetched in absence of Variant BOM." rm_item = create_item('PIV_RM', valuation_rate = 100) if not frappe.db.exists('Item', {"item_code": 'PIV'}): item = create_item('PIV', valuation_rate = 100) @@ -348,7 +402,7 @@ class TestProductionPlan(ERPNextTestCase): frappe.db.rollback() def test_subassmebly_sorting(self): - """ Test subassembly sorting in case of multiple items with nested BOMs""" + "Test subassembly sorting in case of multiple items with nested BOMs." from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom prefix = "_TestLevel_" @@ -386,6 +440,7 @@ class TestProductionPlan(ERPNextTestCase): self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item) def test_multiple_work_order_for_production_plan_item(self): + "Test producing Prod Plan (making WO) in parts." def create_work_order(item, pln, qty): # Get Production Items items_data = pln.get_production_items() @@ -441,7 +496,98 @@ class TestProductionPlan(ERPNextTestCase): pln.reload() self.assertEqual(pln.po_items[0].ordered_qty, 0) + def test_production_plan_pending_qty_with_sales_order(self): + """ + Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel) + """ + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + + make_stock_entry(item_code="Raw Material Item 1", + target="_Test Warehouse - _TC", + qty=2, basic_rate=100 + ) + make_stock_entry(item_code="Raw Material Item 2", + target="_Test Warehouse - _TC", + qty=2, basic_rate=100 + ) + + item = 'Test Production Item 1' + so = make_sales_order(item_code=item, qty=1) + + pln = create_production_plan( + company=so.company, + get_items_from="Sales Order", + sales_order=so, + skip_getting_mr_items=True + ) + self.assertEqual(pln.po_items[0].pending_qty, 1) + + wo = make_wo_order_test_record( + item_code=item, qty=1, + company=so.company, + wip_warehouse='Work In Progress - _TC', + fg_warehouse='Finished Goods - _TC', + skip_transfer=1, + do_not_submit=True + ) + wo.production_plan = pln.name + wo.production_plan_item = pln.po_items[0].name + wo.submit() + + se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1)) + se.submit() + + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 0) + + se.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 1) + + def test_production_plan_pending_qty_independent_items(self): + "Test Prod Plan impact if items are added independently (no from SO or MR)." + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + + pln = create_production_plan( + item_code='Test Production Item 1', + skip_getting_mr_items=True + ) + self.assertEqual(pln.po_items[0].pending_qty, 1) + + wo = make_wo_order_test_record( + item_code='Test Production Item 1', qty=1, + company=pln.company, + wip_warehouse='Work In Progress - _TC', + fg_warehouse='Finished Goods - _TC', + skip_transfer=1, + do_not_submit=True + ) + wo.production_plan = pln.name + wo.production_plan_item = pln.po_items[0].name + wo.submit() + + se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1)) + se.submit() + + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 0) + + se.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 1) + def create_production_plan(**args): + """ + sales_order (obj): Sales Order Doc Object + get_items_from (str): Sales Order/Material Request + skip_getting_mr_items (bool): Whether or not to plan for new MRs + """ args = frappe._dict(args) pln = frappe.get_doc({ @@ -449,20 +595,35 @@ def create_production_plan(**args): 'company': args.company or '_Test Company', 'customer': args.customer or '_Test Customer', 'posting_date': nowdate(), - 'include_non_stock_items': args.include_non_stock_items or 1, - 'include_subcontracted_items': args.include_subcontracted_items or 1, - 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1, - 'po_items': [{ + 'include_non_stock_items': args.include_non_stock_items or 0, + 'include_subcontracted_items': args.include_subcontracted_items or 0, + 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0, + 'get_items_from': 'Sales Order' + }) + + if not args.get("sales_order"): + pln.append('po_items', { 'use_multi_level_bom': args.use_multi_level_bom or 1, 'item_code': args.item_code, 'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'), 'planned_qty': args.planned_qty or 1, 'planned_start_date': args.planned_start_date or now_datetime() - }] - }) - mr_items = get_items_for_material_requests(pln.as_dict()) - for d in mr_items: - pln.append('mr_items', d) + }) + + if args.get("get_items_from") == "Sales Order" and args.get("sales_order"): + so = args.get("sales_order") + pln.append('sales_orders', { + 'sales_order': so.name, + 'sales_order_date': so.transaction_date, + 'customer': so.customer, + 'grand_total': so.grand_total + }) + pln.get_items() + + if not args.get("skip_getting_mr_items"): + mr_items = get_items_for_material_requests(pln.as_dict()) + for d in mr_items: + pln.append('mr_items', d) if not args.do_not_save: pln.insert() diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a86edfa45f..2430afeab7 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -271,7 +271,7 @@ class WorkOrder(Document): produced_qty = total_qty[0][0] if total_qty else 0 - production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item) + production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) def before_submit(self): self.create_serial_no_batch_no() From 1553fa9c907487a928ce0bafcc42259fee2892d7 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 11 Feb 2022 10:57:54 +0530 Subject: [PATCH 083/112] refactor!: drop deprecated and dead code --- erpnext/stock/doctype/bin/bin.py | 19 ------------------- erpnext/stock/stock_balance.py | 3 --- erpnext/stock/utils.py | 10 ---------- 3 files changed, 32 deletions(-) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index d2bae65239..3bc15a8025 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -20,18 +20,6 @@ class Bin(Document): + flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty) - flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract)) - def get_first_sle(self): - sle = frappe.qb.DocType("Stock Ledger Entry") - first_sle = ( - frappe.qb.from_(sle) - .select("*") - .where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse)) - .orderby(sle.posting_date, sle.posting_time, sle.creation) - .limit(1) - ).run(as_dict=True) - - return first_sle and first_sle[0] or None - def update_reserved_qty_for_production(self): '''Update qty reserved for production from Production Item tables in open work orders''' @@ -107,13 +95,6 @@ def on_doctype_update(): frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse") -def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False): - """WARNING: This function is deprecated. Inline this function instead of using it.""" - from erpnext.stock.stock_ledger import repost_current_voucher - - repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher) - update_qty(bin_name, args) - def get_bin_details(bin_name): return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty', 'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production', diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 6663458e65..10a14b9d8d 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -6,7 +6,6 @@ import frappe from frappe.utils import cstr, flt, nowdate, nowtime from erpnext.controllers.stock_controller import create_repost_item_valuation_entry -from erpnext.stock.utils import update_bin def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False): @@ -227,8 +226,6 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin "sle_id": sle_doc.name }) - update_bin(args) - create_repost_item_valuation_entry({ "item_code": d[0], "warehouse": d[1], diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index c75c737fc5..7263e39cc9 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -206,16 +206,6 @@ def _create_bin(item_code, warehouse): return bin_obj -def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False): - """WARNING: This function is deprecated. Inline this function instead of using it.""" - from erpnext.stock.doctype.bin.bin import update_stock - is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item') - if is_stock_item: - bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) - update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher) - else: - frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))) - @frappe.whitelist() def get_incoming_rate(args, raise_error_if_no_rate=True): """Get Incoming Rate based on valuation method""" From 77be98295c836d6fba02ae34f91f36cd99c625a4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 11 Feb 2022 11:29:37 +0530 Subject: [PATCH 084/112] fix: update bin modified timestamp when updating qty These timestamps are used for writing integrations hence whenever bin is updated timestamp should update to reliabily use Bin for integration logic. --- erpnext/buying/doctype/purchase_order/test_purchase_order.py | 5 +++-- erpnext/stock/stock_balance.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 9a63afc130..645e97ee7c 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase): bin1 = frappe.db.get_value("Bin", filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, - fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1) + fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1) # Submit PO po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes") bin2 = frappe.db.get_value("Bin", filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, - fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1) + fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1) self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10) self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10) + self.assertNotEqual(bin1.modified, bin2.modified) # Create stock transfer rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item", diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 10a14b9d8d..62017e4159 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -3,7 +3,7 @@ import frappe -from frappe.utils import cstr, flt, nowdate, nowtime +from frappe.utils import cstr, flt, now, nowdate, nowtime from erpnext.controllers.stock_controller import create_repost_item_valuation_entry @@ -174,6 +174,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None): bin.set(field, flt(value)) mismatch = True + bin.modified = now() if mismatch: bin.set_projected_qty() bin.db_update() From 78dd364b0be9913208d61c402a6c858eb578e210 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 11 Feb 2022 12:32:45 +0530 Subject: [PATCH 085/112] fix: cannot jump to sales invoice in gross profit report --- erpnext/accounts/report/gross_profit/gross_profit.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 685f2d6176..2ba649da07 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -42,6 +42,11 @@ frappe.query_reports["Gross Profit"] = { "parent_field": "parent_invoice", "initial_depth": 3, "formatter": function(value, row, column, data, default_formatter) { + if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) { + column._options = "Sales Invoice"; + } else { + column._options = "Item"; + } value = default_formatter(value, row, column, data); if (data && (data.indent == 0.0 || row[1].content == "Total")) { From c7be9ef5d24a3e03efde64a45302baca76e8107f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 11 Feb 2022 13:52:46 +0530 Subject: [PATCH 086/112] fix: consider leaves for past months if assignment is based on joining date too --- .../leave_policy_assignment.py | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 6168db8502..fa73bdaccf 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,7 +8,7 @@ from math import ceil import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate +from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate class LeavePolicyAssignment(Document): @@ -94,10 +94,12 @@ class LeavePolicyAssignment(Document): new_leaves_allocated = 0 elif leave_type_details.get(leave_type).is_earned_leave == 1: - if self.assignment_based_on == "Leave Period": - new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining) - else: + if not self.assignment_based_on: new_leaves_allocated = 0 + else: + # get leaves for past months if assignment is based on Leave Period / Joining Date + new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining) + # Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period elif getdate(date_of_joining) > getdate(self.effective_from): remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1)) @@ -108,25 +110,23 @@ class LeavePolicyAssignment(Document): def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): from erpnext.hr.utils import get_monthly_earned_leave - current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month - current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year + current_date = frappe.flags.current_date or getdate() + if current_date > getdate(self.effective_to): + current_date = getdate(self.effective_to) - from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date") - if getdate(date_of_joining) > getdate(from_date): - from_date = date_of_joining - - from_date_month = get_datetime(from_date).month - from_date_year = get_datetime(from_date).year + from_date = getdate(self.effective_from) + if getdate(date_of_joining) > from_date: + from_date = getdate(date_of_joining) months_passed = 0 based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining - if current_year == from_date_year and current_month >= from_date_month: - months_passed = current_month - from_date_month + if current_date.year == from_date.year and current_date.month >= from_date.month: + months_passed = current_date.month - from_date.month months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) - elif current_year > from_date_year: - months_passed = (12 - from_date_month) + current_month + elif current_date.year > from_date.year: + months_passed = (12 - from_date.month) + current_date.month months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) if months_passed > 0: @@ -143,8 +143,7 @@ def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj date = getdate(frappe.flags.current_date) or getdate() if based_on_doj: - # if leave type allocation is based on DOJ, - # and the date of assignment creation is same as DOJ, + # if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ, # then the month should be considered if date == date_of_joining: months_passed += 1 From d93d2a80b10c94cc2d7f8b5a3601d0efec8cbf2d Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Fri, 11 Feb 2022 15:12:25 +0530 Subject: [PATCH 087/112] chore: remove deprecated print format --- .../print_format/gst_pos_invoice/__init__.py | 0 .../gst_pos_invoice/gst_pos_invoice.json | 23 ------------------- 2 files changed, 23 deletions(-) delete mode 100644 erpnext/accounts/print_format/gst_pos_invoice/__init__.py delete mode 100644 erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json diff --git a/erpnext/accounts/print_format/gst_pos_invoice/__init__.py b/erpnext/accounts/print_format/gst_pos_invoice/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json b/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json deleted file mode 100644 index 1aa1c02968..0000000000 --- a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "align_labels_right": 0, - "creation": "2017-08-08 12:33:04.773099", - "custom_format": 1, - "disabled": 0, - "doc_type": "Sales Invoice", - "docstatus": 0, - "doctype": "Print Format", - "font": "Default", - "html": "\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n

\n\t{{ doc.company }}
\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"
\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t{{ _(\"GSTIN\") }}:{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"
GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t
\n\t{% if doc.docstatus == 0 %}\n\t\t{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}
\n\t{% else %}\n\t\t{{ doc.select_print_heading or _(\"Invoice\") }}
\n\t{% endif %}\n

\n

\n\t{{ _(\"Receipt No\") }}: {{ doc.name }}
\n\t{{ _(\"Date\") }}: {{ doc.get_formatted(\"posting_date\") }}
\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"
\", \" \") %}\n\t\t{{ _(\"Customer\") }}:
\n\t\t{{ doc.customer_name }}
\n\t\t{{ customer_address }}\n\t{% endif %}\n

\n\n
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t{%- for item in doc.items -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endfor -%}\n\t\n
{{ _(\"Item\") }}{{ _(\"Qty\") }}{{ _(\"Amount\") }}
\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t
{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t
{{ _(\"HSN/SAC\") }}: {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t
{{ _(\"Serial No\") }}: {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ item.qty }}
@ {{ item.rate }}
{{ item.get_formatted(\"amount\") }}
\n\n\t\n\t\t\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t\n\t\t\t\t\n\t\t\t{% else %}\n\t\t\t\t\n\t\t\t\t\n\t\t\t{% endif %}\n\t\t\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endif -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- if doc.rounded_total -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endif -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t{%- if doc.change_amount -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t{%- endif -%}\n\t\n
\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t
\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t
\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Grand Total\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Rounded Total\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Paid Amount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Change Amount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t
\n

{{ doc.terms or \"\" }}

\n

{{ _(\"Thank you, please visit again.\") }}

", - "idx": 0, - "line_breaks": 0, - "modified": "2020-04-29 16:39:12.936215", - "modified_by": "Administrator", - "module": "Accounts", - "name": "GST POS Invoice", - "owner": "Administrator", - "print_format_builder": 0, - "print_format_type": "Jinja", - "raw_printing": 0, - "show_section_headings": 0, - "standard": "Yes" -} \ No newline at end of file From ea20c63182ba0b380aa46bab438ed45db0f19e8a Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Fri, 11 Feb 2022 17:38:37 +0530 Subject: [PATCH 088/112] Revert "fix(India): Tax calculation for overseas suppliers" --- erpnext/regional/india/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 8715ef57ba..d443f9c15c 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -219,7 +219,6 @@ def get_regional_address_details(party_details, doctype, company): if not party_details.place_of_supply: return party_details if not party_details.company_gstin: return party_details - if not party_details.supplier_gstin: return party_details if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice", From 51e608682934610d3414e40f9524e529c4a36f49 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 11 Feb 2022 17:40:20 +0530 Subject: [PATCH 089/112] chore: clean-up leave policy assignment tests --- .../leave_policy_assignment.py | 1 - .../test_leave_policy_assignment.py | 116 ++++++------------ 2 files changed, 36 insertions(+), 81 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index fa73bdaccf..1917f22e5e 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -12,7 +12,6 @@ from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate class LeavePolicyAssignment(Document): - def validate(self): self.validate_policy_assignment_overlap() self.set_dates() diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 8c76ca1cc3..dcdd7b9fb3 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -22,34 +22,27 @@ class TestLeavePolicyAssignment(unittest.TestCase): for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec + self.employee = get_employee() + def test_grant_leaves(self): leave_period = get_leave_period() - employee = get_employee() - - # create the leave policy with leave type "_Test Leave Type", allocation = 10 + # allocation = 10 leave_policy = create_leave_policy() leave_policy.submit() - data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, "leave_period": leave_period.name } - - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) - - leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) - leave_policy_assignment_doc.reload() - - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1) leave_allocation = frappe.get_list("Leave Allocation", filters={ - "employee": employee.name, + "employee": self.employee.name, "leave_policy":leave_policy.name, "leave_policy_assignment": leave_policy_assignments[0], "docstatus": 1})[0] - leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10) @@ -61,49 +54,32 @@ class TestLeavePolicyAssignment(unittest.TestCase): def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self): leave_period = get_leave_period() - employee = get_employee() - # create the leave policy with leave type "_Test Leave Type", allocation = 10 leave_policy = create_leave_policy() leave_policy.submit() - data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, "leave_period": leave_period.name } - - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) - - leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) - leave_policy_assignment_doc.reload() - + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) # every leave is allocated no more leave can be granted now - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) - + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1) leave_allocation = frappe.get_list("Leave Allocation", filters={ - "employee": employee.name, + "employee": self.employee.name, "leave_policy":leave_policy.name, "leave_policy_assignment": leave_policy_assignments[0], "docstatus": 1})[0] leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) - - # User all allowed to grant leave when there is no allocation against assignment leave_alloc_doc.cancel() leave_alloc_doc.delete() - - leave_policy_assignment_doc.reload() - - - # User are now allowed to grant leave - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0) + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0) def test_earned_leave_allocation(self): leave_period = create_leave_period("Test Earned Leave Period") - employee = get_employee() leave_type = create_earned_leave_type("Test Earned Leave") leave_policy = frappe.get_doc({ @@ -117,7 +93,7 @@ class TestLeavePolicyAssignment(unittest.TestCase): "leave_policy": leave_policy.name, "leave_period": leave_period.name } - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) # leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency leaves_allocated = frappe.db.get_value("Leave Allocation", { @@ -125,16 +101,8 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 0) - def test_earned_leave_allocation_for_passed_months(self): - employee = get_employee() - leave_type = create_earned_leave_type("Test Earned Leave") - leave_period = create_leave_period("Test Earned Leave Period", - start_date=get_first_day(add_months(getdate(), -1))) - leave_policy = frappe.get_doc({ - "doctype": "Leave Policy", - "title": "Test Leave Policy", - "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] - }).insert() + def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self): + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1))) # Case 1: assignment created one month after the leave period, should allocate 1 leave frappe.flags.current_date = get_first_day(getdate()) @@ -143,24 +111,15 @@ class TestLeavePolicyAssignment(unittest.TestCase): "leave_policy": leave_policy.name, "leave_period": leave_period.name } - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) leaves_allocated = frappe.db.get_value("Leave Allocation", { "leave_policy_assignment": leave_policy_assignments[0] }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 1) - def test_earned_leave_allocation_for_passed_months_on_month_end(self): - employee = get_employee() - leave_type = create_earned_leave_type("Test Earned Leave") - leave_period = create_leave_period("Test Earned Leave Period", - start_date=get_first_day(add_months(getdate(), -2))) - leave_policy = frappe.get_doc({ - "doctype": "Leave Policy", - "title": "Test Leave Policy", - "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] - }).insert() - + def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self): + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2))) # Case 2: assignment created on the last day of the leave period's latter month # should allocate 1 leave for current month even though the month has not ended # since the daily job might have already executed @@ -171,7 +130,7 @@ class TestLeavePolicyAssignment(unittest.TestCase): "leave_policy": leave_policy.name, "leave_period": leave_period.name } - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) leaves_allocated = frappe.db.get_value("Leave Allocation", { "leave_policy_assignment": leave_policy_assignments[0] @@ -188,33 +147,17 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 3) - def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self): + def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self): from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation - employee = get_employee() - leave_type = create_earned_leave_type("Test Earned Leave") - leave_period = create_leave_period("Test Earned Leave Period", - start_date=get_first_day(add_months(getdate(), -2))) - leave_policy = frappe.get_doc({ - "doctype": "Leave Policy", - "title": "Test Leave Policy", - "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] - }).insert() - + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2))) # initial leave allocation = 5 - leave_allocation = create_leave_allocation( - employee=employee.name, - employee_name=employee.employee_name, - leave_type=leave_type.name, - from_date=add_months(getdate(), -12), - to_date=add_months(getdate(), -3), - new_leaves_allocated=5, - carry_forward=0) + leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave", + from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0) leave_allocation.submit() # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding frappe.flags.current_date = get_last_day(add_months(getdate(), -1)) - data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, @@ -222,7 +165,7 @@ class TestLeavePolicyAssignment(unittest.TestCase): "carry_forward": 1 } # carry forwarded leaves = 5, 3 leaves allocated for passed months - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) details = frappe.db.get_value("Leave Allocation", { "leave_policy_assignment": leave_policy_assignments[0] @@ -269,4 +212,17 @@ def create_leave_period(name, start_date=None): to_date=add_months(start_date, 12), company="_Test Company", is_active=1 - )).insert() \ No newline at end of file + )).insert() + + +def setup_leave_period_and_policy(start_date): + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=start_date) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + return leave_period, leave_policy \ No newline at end of file From f62b3207ff4c947f2f45006755134761c30bec96 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 11 Feb 2022 18:14:28 +0530 Subject: [PATCH 090/112] fix: Generate Wh wise FIFO Queue and later aggregate if required - Back to back stock recos cause incorrect qty calculation across warehouses - Hard to differentiate how much of the qty is reset by the reco - Maintain Queue and balances warehouse wise and later aggregate for accurate values --- .../stock/report/stock_ageing/stock_ageing.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e6dfc97a99..a89a4038c2 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -252,6 +252,7 @@ class FIFOSlots: key, fifo_queue, transferred_item_key = self.__init_key_stores(d) if d.voucher_type == "Stock Reconciliation": + # get difference in qty shift as actual qty prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) @@ -264,12 +265,16 @@ class FIFOSlots: self.__update_balances(d, key) + if not self.filters.get("show_warehouse_wise_stock"): + # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) + self.item_details = self.__aggregate_details_by_item(self.item_details) + return self.item_details def __init_key_stores(self, row: Dict) -> Tuple: "Initialise keys and FIFO Queue." - key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name + key = (row.name, row.warehouse) self.item_details.setdefault(key, {"details": row, "fifo_queue": []}) fifo_queue = self.item_details[key]["fifo_queue"] @@ -338,6 +343,27 @@ class FIFOSlots: self.item_details[key]["has_serial_no"] = row.has_serial_no + def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict: + "Aggregate Item-Wh wise data into single Item entry." + item_aggregated_data = {} + for key,row in wh_wise_data.items(): + item = key[0] + if not item_aggregated_data.get(item): + item_aggregated_data.setdefault(item, { + "details": frappe._dict(), + "fifo_queue": [], + "qty_after_transaction": 0.0, + "total_qty": 0.0 + }) + item_row = item_aggregated_data.get(item) + item_row["details"].update(row["details"]) + item_row["fifo_queue"].extend(row["fifo_queue"]) + item_row["qty_after_transaction"] += flt(row["qty_after_transaction"]) + item_row["total_qty"] += flt(row["total_qty"]) + item_row["has_serial_no"] = row["has_serial_no"] + + return item_aggregated_data + def __get_stock_ledger_entries(self) -> List[Dict]: sle = frappe.qb.DocType("Stock Ledger Entry") item = self.__get_item_query() # used as derived table in sle query From 9b0f9c344282c9cad5334c6e3b46aa1c74826f9b Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 11 Feb 2022 20:08:01 +0530 Subject: [PATCH 091/112] test: earned leave allocations based on DOJ --- .../leave_policy_assignment.py | 2 +- .../test_leave_policy_assignment.py | 134 ++++++++++++++++-- erpnext/hr/utils.py | 5 +- 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 1917f22e5e..c11a821738 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -144,7 +144,7 @@ def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj if based_on_doj: # if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ, # then the month should be considered - if date == date_of_joining: + if date.day == date_of_joining.day: months_passed += 1 else: last_day_of_month = get_last_day(date) diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index dcdd7b9fb3..862a1c504a 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -20,7 +20,7 @@ test_dependencies = ["Employee"] class TestLeavePolicyAssignment(unittest.TestCase): def setUp(self): for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: - frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec + frappe.db.delete(doctype) self.employee = get_employee() @@ -86,7 +86,7 @@ class TestLeavePolicyAssignment(unittest.TestCase): "doctype": "Leave Policy", "title": "Test Leave Policy", "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}] - }).insert() + }).submit() data = { "assignment_based_on": "Leave Period", @@ -118,7 +118,7 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 1) - def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self): + def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self): leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2))) # Case 2: assignment created on the last day of the leave period's latter month # should allocate 1 leave for current month even though the month has not ended @@ -179,15 +179,132 @@ class TestLeavePolicyAssignment(unittest.TestCase): from erpnext.hr.utils import is_earned_leave_already_allocated frappe.flags.current_date = get_last_day(getdate()) - allocation = frappe.get_doc('Leave Allocation', details.name) + allocation = frappe.get_doc("Leave Allocation", details.name) # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation)) + def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self): + # tests leave alloc for earned leaves for assignment based on joining date in policy assignment + leave_type = create_earned_leave_type("Test Earned Leave") + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).submit() + + # joining date set to 2 months back + doj = self.employee.date_of_joining + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the last day of the current month + frappe.flags.current_date = get_last_day(getdate()) + data = { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from") + self.assertEqual(effective_from, self.employee.date_of_joining) + self.assertEqual(leaves_allocated, 3) + + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_last_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # reset DOJ + frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) + + def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self): + # tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True) + + # joining date set to 2 months back + doj = self.employee.date_of_joining + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the same day of the current month, should allocate leaves including the current month + frappe.flags.current_date = get_first_day(getdate()) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_first_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # reset DOJ + frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) + + def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self): + # tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type + leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).submit() + + # joining date set to 2 months back + # leave should be allocated for current month too since this day is same as the joining day + doj = self.employee.date_of_joining + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the first day of the current month + frappe.flags.current_date = get_first_day(getdate()) + data = { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from") + self.assertEqual(effective_from, self.employee.date_of_joining) + self.assertEqual(leaves_allocated, 3) + + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_first_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # reset DOJ + frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) + def tearDown(self): frappe.db.rollback() -def create_earned_leave_type(leave_type): +def create_earned_leave_type(leave_type, based_on_doj=False): frappe.delete_doc_if_exists("Leave Type", leave_type, force=1) return frappe.get_doc(dict( @@ -196,7 +313,8 @@ def create_earned_leave_type(leave_type): is_earned_leave=1, earned_leave_frequency="Monthly", rounding=0.5, - is_carry_forward=1 + is_carry_forward=1, + based_on_date_of_joining=based_on_doj )).insert() @@ -215,8 +333,8 @@ def create_leave_period(name, start_date=None): )).insert() -def setup_leave_period_and_policy(start_date): - leave_type = create_earned_leave_type("Test Earned Leave") +def setup_leave_period_and_policy(start_date, based_on_doj=False): + leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj) leave_period = create_leave_period("Test Earned Leave Period", start_date=start_date) leave_policy = frappe.get_doc({ diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index ae4411b851..c1740471e2 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -305,13 +305,10 @@ def is_earned_leave_already_allocated(allocation, annual_allocation): get_leave_type_details, ) - assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) - if assignment.assignment_based_on == "Joining Date": - return False - leave_type_details = get_leave_type_details() date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") + assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, annual_allocation, leave_type_details, date_of_joining) From cbaadcf1138cba113cc18c6d2bc2690e144cf9d0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 11 Feb 2022 20:59:19 +0530 Subject: [PATCH 092/112] fix(test): reset test setup --- .../test_leave_policy_assignment.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 862a1c504a..a19ddce7c0 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -22,7 +22,9 @@ class TestLeavePolicyAssignment(unittest.TestCase): for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: frappe.db.delete(doctype) - self.employee = get_employee() + employee = get_employee() + self.original_doj = employee.date_of_joining + self.employee = employee def test_grant_leaves(self): leave_period = get_leave_period() @@ -193,7 +195,6 @@ class TestLeavePolicyAssignment(unittest.TestCase): }).submit() # joining date set to 2 months back - doj = self.employee.date_of_joining self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) self.employee.save() @@ -219,15 +220,11 @@ class TestLeavePolicyAssignment(unittest.TestCase): "total_leaves_allocated") self.assertEqual(leaves_allocated, 3) - # reset DOJ - frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) - def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self): # tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True) # joining date set to 2 months back - doj = self.employee.date_of_joining self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) self.employee.save() @@ -257,9 +254,6 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 3) - # reset DOJ - frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) - def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self): # tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True) @@ -271,7 +265,6 @@ class TestLeavePolicyAssignment(unittest.TestCase): # joining date set to 2 months back # leave should be allocated for current month too since this day is same as the joining day - doj = self.employee.date_of_joining self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) self.employee.save() @@ -297,11 +290,10 @@ class TestLeavePolicyAssignment(unittest.TestCase): "total_leaves_allocated") self.assertEqual(leaves_allocated, 3) - # reset DOJ - frappe.db.set_value("Employee", self.employee.name, "date_of_joining", doj) - def tearDown(self): frappe.db.rollback() + frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj) + frappe.flags.current_date = None def create_earned_leave_type(leave_type, based_on_doj=False): From 961467c3899a65ea68e9d2052563d85ffdc4d5f6 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sat, 12 Feb 2022 00:29:53 +0530 Subject: [PATCH 093/112] fix: revert removal of gratuity payment via salary slip (#29589) --- erpnext/payroll/doctype/gratuity/gratuity.js | 10 ++++- .../payroll/doctype/gratuity/gratuity.json | 43 +++++++++++++++---- erpnext/payroll/doctype/gratuity/gratuity.py | 18 +++++++- .../doctype/gratuity/gratuity_dashboard.py | 2 +- .../payroll/doctype/gratuity/test_gratuity.py | 34 +++++++++------ 5 files changed, 82 insertions(+), 25 deletions(-) diff --git a/erpnext/payroll/doctype/gratuity/gratuity.js b/erpnext/payroll/doctype/gratuity/gratuity.js index d4f7c9ca09..3d69c46e55 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.js +++ b/erpnext/payroll/doctype/gratuity/gratuity.js @@ -3,6 +3,14 @@ frappe.ui.form.on('Gratuity', { setup: function (frm) { + frm.set_query("salary_component", function () { + return { + filters: { + type: "Earning" + } + }; + }); + frm.set_query("expense_account", function () { return { filters: { @@ -24,7 +32,7 @@ frappe.ui.form.on('Gratuity', { }); }, refresh: function (frm) { - if (frm.doc.docstatus == 1 && frm.doc.status == "Unpaid") { + if (frm.doc.docstatus == 1 && !frm.doc.pay_via_salary_slip && frm.doc.status == "Unpaid") { frm.add_custom_button(__("Create Payment Entry"), function () { return frappe.call({ method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry', diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json index 197089567d..1fd1cecaaa 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.json +++ b/erpnext/payroll/doctype/gratuity/gratuity.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "HR-GRA-PAY-.#####", - "creation": "2020-08-05 20:52:13.024683", + "creation": "2022-01-27 16:24:28.200061", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -16,6 +16,9 @@ "company", "gratuity_rule", "section_break_5", + "pay_via_salary_slip", + "payroll_date", + "salary_component", "payable_account", "expense_account", "mode_of_payment", @@ -78,18 +81,20 @@ "reqd": 1 }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "expense_account", "fieldtype": "Link", "label": "Expense Account", - "options": "Account", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Account" }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "mode_of_payment", "fieldtype": "Link", "label": "Mode of Payment", - "options": "Mode of Payment", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Mode of Payment" }, { "fieldname": "gratuity_rule", @@ -151,23 +156,45 @@ "read_only": 1 }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "payable_account", "fieldtype": "Link", "label": "Payable Account", - "options": "Account", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Account" }, { "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" + }, + { + "default": "1", + "fieldname": "pay_via_salary_slip", + "fieldtype": "Check", + "label": "Pay via Salary Slip" + }, + { + "depends_on": "pay_via_salary_slip", + "fieldname": "payroll_date", + "fieldtype": "Date", + "label": "Payroll Date", + "mandatory_depends_on": "pay_via_salary_slip" + }, + { + "depends_on": "pay_via_salary_slip", + "fieldname": "salary_component", + "fieldtype": "Link", + "label": "Salary Component", + "mandatory_depends_on": "pay_via_salary_slip", + "options": "Salary Component" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-19 12:54:37.306145", + "modified": "2022-02-02 14:00:45.536152", "modified_by": "Administrator", "module": "Payroll", "name": "Gratuity", diff --git a/erpnext/payroll/doctype/gratuity/gratuity.py b/erpnext/payroll/doctype/gratuity/gratuity.py index 476990a88e..939634a931 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.py +++ b/erpnext/payroll/doctype/gratuity/gratuity.py @@ -21,7 +21,10 @@ class Gratuity(AccountsController): self.status = "Unpaid" def on_submit(self): - self.create_gl_entries() + if self.pay_via_salary_slip: + self.create_additional_salary() + else: + self.create_gl_entries() def on_cancel(self): self.ignore_linked_doctypes = ['GL Entry'] @@ -64,6 +67,19 @@ class Gratuity(AccountsController): return gl_entry + def create_additional_salary(self): + if self.pay_via_salary_slip: + additional_salary = frappe.new_doc('Additional Salary') + additional_salary.employee = self.employee + additional_salary.salary_component = self.salary_component + additional_salary.overwrite_salary_structure_amount = 0 + additional_salary.amount = self.amount + additional_salary.payroll_date = self.payroll_date + additional_salary.company = self.company + additional_salary.ref_doctype = self.doctype + additional_salary.ref_docname = self.name + additional_salary.submit() + def set_total_advance_paid(self): paid_amount = frappe.db.sql(""" select ifnull(sum(debit_in_account_currency), 0) as paid_amount diff --git a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py index aeadba186d..771a6fea84 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py +++ b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py @@ -10,7 +10,7 @@ def get_data(): 'transactions': [ { 'label': _('Payment'), - 'items': ['Payment Entry'] + 'items': ['Payment Entry', 'Additional Salary'] } ] } diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py index 93cba06da1..90e8061fed 100644 --- a/erpnext/payroll/doctype/gratuity/test_gratuity.py +++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py @@ -18,27 +18,25 @@ from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule test_dependencies = ["Salary Component", "Salary Slip", "Account"] class TestGratuity(unittest.TestCase): - @classmethod - def setUpClass(cls): + def setUp(self): + frappe.db.delete("Gratuity") + frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"}) + make_earning_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) make_deduction_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) - def setUp(self): - frappe.db.sql("DELETE FROM `tabGratuity`") - def test_get_last_salary_slip_should_return_none_for_new_employee(self): new_employee = make_employee("new_employee@salary.com", company='_Test Company') salary_slip = get_last_salary_slip(new_employee) assert salary_slip is None - def test_check_gratuity_amount_based_on_current_slab(self): + def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self): employee, sal_slip = create_employee_and_get_last_salary_slip() rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)") + gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name) - gratuity = create_gratuity(employee=employee, rule=rule.name) - - #work experience calculation + # work experience calculation date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date']) employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days @@ -64,6 +62,9 @@ class TestGratuity(unittest.TestCase): self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2)) + # additional salary creation (Pay via salary slip) + self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name})) + def test_check_gratuity_amount_based_on_all_previous_slabs(self): employee, sal_slip = create_employee_and_get_last_salary_slip() rule = get_gratuity_rule("Rule Under Limited Contract (UAE)") @@ -117,8 +118,8 @@ class TestGratuity(unittest.TestCase): self.assertEqual(flt(gratuity.paid_amount,2), flt(gratuity.amount, 2)) def tearDown(self): - frappe.db.sql("DELETE FROM `tabGratuity`") - frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'") + frappe.db.rollback() + def get_gratuity_rule(name): rule = frappe.db.exists("Gratuity Rule", name) @@ -141,9 +142,14 @@ def create_gratuity(**args): gratuity.employee = args.employee gratuity.posting_date = getdate() gratuity.gratuity_rule = args.rule or "Rule Under Limited Contract (UAE)" - gratuity.expense_account = args.expense_account or 'Payment Account - _TC' - gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") - gratuity.mode_of_payment = args.mode_of_payment or 'Cash' + gratuity.pay_via_salary_slip = args.pay_via_salary_slip or 0 + if gratuity.pay_via_salary_slip: + gratuity.payroll_date = getdate() + gratuity.salary_component = "Performance Bonus" + else: + gratuity.expense_account = args.expense_account or 'Payment Account - _TC' + gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") + gratuity.mode_of_payment = args.mode_of_payment or 'Cash' gratuity.save() gratuity.submit() From 749005eb8b77e3cadfb5d90a2a1a88e50938a2e4 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 12 Feb 2022 12:14:19 +0530 Subject: [PATCH 094/112] fix: list mutation within loop (#29766) Prevent list mutation within loop leading to incorrect data (cherry picked from commit 894a406ed406f8e6fa3efed9315609ffc33075f6) Co-authored-by: Govind S Menokee --- erpnext/payroll/doctype/payroll_entry/payroll_entry.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index db88c0643c..a634dfe8c1 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -527,11 +527,12 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): """ % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True) def remove_payrolled_employees(emp_list, start_date, end_date): + new_emp_list = [] for employee_details in emp_list: - if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}): - emp_list.remove(employee_details) + if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}): + new_emp_list.append(employee_details) - return emp_list + return new_emp_list @frappe.whitelist() def get_start_end_dates(payroll_frequency, start_date=None, company=None): From eb8b424722826b76fa1c208e5731ef12c79a0555 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 12 Feb 2022 13:08:28 +0530 Subject: [PATCH 095/112] feat: item-wise negative stock setting (#29761) --- .../doctype/pos_invoice/pos_invoice.py | 5 ++- erpnext/stock/doctype/item/item.json | 9 ++++- erpnext/stock/doctype/item/test_item.py | 40 +++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 3 +- erpnext/stock/stock_ledger.py | 19 +++++---- 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 97d34e0a71..5229d87017 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -172,9 +172,10 @@ class POSInvoice(SalesInvoice): frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) def validate_stock_availablility(self): + from erpnext.stock.stock_ledger import is_negative_stock_allowed + if self.is_return or self.docstatus != 1: return - allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') for d in self.get('items'): is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item')) if is_service_item: @@ -186,7 +187,7 @@ class POSInvoice(SalesInvoice): elif d.batch_no: self.validate_pos_reserved_batch_qty(d) else: - if allow_negative_stock: + if is_negative_stock_allowed(item_code=d.item_code): return available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index b05f58a982..c797187850 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -48,6 +48,7 @@ "warranty_period", "weight_per_unit", "weight_uom", + "allow_negative_stock", "reorder_section", "reorder_levels", "unit_of_measure_conversion", @@ -907,6 +908,12 @@ "fieldname": "is_grouped_asset", "fieldtype": "Check", "label": "Create Grouped Asset" + }, + { + "default": "0", + "fieldname": "allow_negative_stock", + "fieldtype": "Check", + "label": "Allow Negative Stock" } ], "icon": "fa fa-tag", @@ -914,7 +921,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-01-18 12:57:54.273202", + "modified": "2022-02-11 08:07:46.663220", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index fc45ba99c4..fd4df42187 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -6,6 +6,7 @@ import json import frappe from frappe.test_runner import make_test_objects +from frappe.utils import add_days, today from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, @@ -608,6 +609,45 @@ class TestItem(ERPNextTestCase): item.item_group = "All Item Groups" item.save() # if item code saved without item_code then series worked + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_item_wise_negative_stock(self): + """ When global settings are disabled check that item that allows + negative stock can still consume material in all known stock + transactions that consume inventory.""" + from erpnext.stock.stock_ledger import is_negative_stock_allowed + + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + self.assertTrue(is_negative_stock_allowed(item_code=item.name)) + + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_backdated_negative_stock(self): + """ same as test above but backdated entries """ + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + + # create a future entry so all new entries are backdated + make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5)) + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + + def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + typical_args = {"item_code": item_code, "warehouse": warehouse} + + create_delivery_note(**typical_args) + create_sales_invoice(update_stock=1, **typical_args) + make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue") + make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1) + # standalone return + make_purchase_receipt(is_return=True, qty=-1, **typical_args) + + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 782fcf04a5..9ba007a186 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -433,9 +433,10 @@ class StockEntry(StockController): ) def set_actual_qty(self): - allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock")) + from erpnext.stock.stock_ledger import is_negative_stock_allowed for d in self.get('items'): + allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code) previous_sle = get_previous_sle({ "item_code": d.item_code, "warehouse": d.s_warehouse or d.t_warehouse, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 41c4002e3f..00ca81f2b4 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -3,6 +3,7 @@ import copy import json +from typing import Optional import frappe from frappe import _ @@ -268,11 +269,10 @@ class update_entries_after(object): self.verbose = verbose self.allow_zero_rate = allow_zero_rate self.via_landed_cost_voucher = via_landed_cost_voucher - self.allow_negative_stock = allow_negative_stock \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + self.item_code = args.get("item_code") + self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code) self.args = frappe._dict(args) - self.item_code = args.get("item_code") if self.args.sle_id: self.args['name'] = self.args.sle_id @@ -1049,10 +1049,7 @@ def get_datetime_limit_condition(detail): )""" def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): - allow_negative_stock = cint(allow_negative_stock) \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - - if allow_negative_stock: + if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code): return if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"): return @@ -1121,3 +1118,11 @@ def get_future_sle_with_negative_batch_qty(args): and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) limit 1 """, args, as_dict=1) + + +def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool: + if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)): + return True + if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)): + return True + return False From 7338641791f726119f4a216ae5a9be88dc26170c Mon Sep 17 00:00:00 2001 From: Wolfram Schmidt Date: Sat, 12 Feb 2022 10:19:31 +0100 Subject: [PATCH 096/112] chore: added DocType Translation #29730 frappe.cloud/app/doctype/Email Digest Recipient ..it's just a table [skip ci] --- erpnext/translations/de.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 4a6c83406f..2267dd3dc5 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -913,6 +913,7 @@ Email Account,E-Mail-Konto, Email Address,E-Mail-Adresse, "Email Address must be unique, already exists for {0}","E-Mail-Adresse muss eindeutig sein, diese wird bereits für {0} verwendet", Email Digest: ,E-Mail-Bericht:, +Email Digest Recipient,E-Mail-Berichtsempfänger, Email Reminders will be sent to all parties with email contacts,E-Mail-Erinnerungen werden an alle Parteien mit E-Mail-Kontakten gesendet, Email Sent,E-Mail wurde versandt, Email Template,E-Mail-Vorlage, From a0d163ebff6444363be4b927946adad188114e24 Mon Sep 17 00:00:00 2001 From: Wolfram Schmidt Date: Sat, 12 Feb 2022 10:20:48 +0100 Subject: [PATCH 097/112] chore: changed Region zu Gebiet (#29651) "Region" is more fitting when talking about a geographical area or region. Gebiet is more accurate translation in this case of territory as we are talking about sales territory which would be a "Verkaufsgebiet". --- erpnext/translations/de.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 2267dd3dc5..cf73564b9e 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -2945,7 +2945,7 @@ Temporary Accounts,Temporäre Konten, Temporary Opening,Temporäre Eröffnungskonten, Terms and Conditions,Allgemeine Geschäftsbedingungen, Terms and Conditions Template,Vorlage für Allgemeine Geschäftsbedingungen, -Territory,Region, +Territory,Gebiet, Test,Test, Thank you,Danke, Thank you for your business!,Vielen Dank für Ihr Unternehmen!, From 988dee04fa2e0d6fff1d0686674e1e7b40fb56d0 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 8 Feb 2022 22:56:14 +0530 Subject: [PATCH 098/112] fix: Loan repayment via Salary Slip (cherry picked from commit 2572480554db265e2e93a5dfba75749675b46d14) --- .../doctype/loan_repayment/loan_repayment.py | 6 ++++-- erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py | 2 +- erpnext/payroll/doctype/salary_slip/test_salary_slip.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index acf3a655de..f3ed611255 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -345,7 +345,7 @@ class LoanRepayment(AccountsController): gle_map.append( self.get_gl_dict({ "account": loan_details.penalty_income_account, - "against": payment_account, + "against": loan_details.loan_account, "credit": self.total_penalty_paid, "credit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", @@ -367,7 +367,9 @@ class LoanRepayment(AccountsController): "against_voucher": self.against_loan, "remarks": remarks, "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) + "posting_date": getdate(self.posting_date), + "party_type": loan_details.applicant_type if self.repay_from_salary else '', + "party": loan_details.applicant if self.repay_from_salary else '' }) ) diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index 5f836db2f0..10d2668c5b 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -124,7 +124,7 @@ class TestPayrollEntry(unittest.TestCase): if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): create_account(account_name="_Test Payroll Payable", - company="_Test Company", parent_account="Current Liabilities - _TC") + company="_Test Company", parent_account="Current Liabilities - _TC", account_type=None) if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \ frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC": diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 30b604b2c0..f83053e12d 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -726,7 +726,7 @@ def get_salary_component_account(sal_comp, company_list=None): }) sal_comp.save() -def create_account(account_name, company, parent_account): +def create_account(account_name, company, parent_account, account_type=None): company_abbr = frappe.get_cached_value('Company', company, 'abbr') account = frappe.db.get_value("Account", account_name + " - " + company_abbr) if not account: From 65711dbde822d2793c24e33d9445ec9c8483a9de Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Wed, 9 Feb 2022 13:44:14 +0530 Subject: [PATCH 099/112] test: Update account type in payroll payable account (cherry picked from commit a54e0fe42b38f571396e88b3ebcc46fd3b389301) --- erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index 10d2668c5b..3b7f4b2ba7 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -124,7 +124,7 @@ class TestPayrollEntry(unittest.TestCase): if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): create_account(account_name="_Test Payroll Payable", - company="_Test Company", parent_account="Current Liabilities - _TC", account_type=None) + company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable") if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \ frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC": From 85adb947bf6908942c5272adc47d1e5ef06c10b3 Mon Sep 17 00:00:00 2001 From: Dany Robert Date: Mon, 14 Feb 2022 09:22:21 +0530 Subject: [PATCH 100/112] fix: show user id in emp group table (#29776) --- .../doctype/employee_group_table/employee_group_table.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/employee_group_table/employee_group_table.json b/erpnext/hr/doctype/employee_group_table/employee_group_table.json index 4e0045cdeb..54eb8c6da9 100644 --- a/erpnext/hr/doctype/employee_group_table/employee_group_table.json +++ b/erpnext/hr/doctype/employee_group_table/employee_group_table.json @@ -27,12 +27,13 @@ "fetch_from": "employee.user_id", "fieldname": "user_id", "fieldtype": "Data", + "in_list_view": 1, "label": "ERPNext User ID", "read_only": 1 } ], "istable": 1, - "modified": "2019-06-06 10:41:20.313756", + "modified": "2022-02-13 19:44:21.302938", "modified_by": "Administrator", "module": "HR", "name": "Employee Group Table", @@ -42,4 +43,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} From 3713ae75ab16ea7ca469ab82d529da571583cea2 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 14 Feb 2022 11:33:34 +0530 Subject: [PATCH 101/112] fix: incorrect pricing rule filtering on selecting first item --- erpnext/stock/get_item_details.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 06f8fa71a9..d37dc7ad9e 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -343,6 +343,7 @@ def get_basic_details(args, item, overwrite_warehouse=True): args.conversion_factor = out.conversion_factor out.stock_qty = out.qty * out.conversion_factor + args.stock_qty = out.stock_qty # calculate last purchase rate if args.get('doctype') in purchase_doctypes: From 18d0a59a9d4d4fd35ce997f2d23aa7ced930b00e Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 14 Feb 2022 18:38:02 +0530 Subject: [PATCH 102/112] fix: disable rounded total in opening invoice creation tool --- .../opening_invoice_creation_tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index 19d8d49dfe..ade7f8146b 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -167,7 +167,8 @@ class OpeningInvoiceCreationTool(Document): "is_pos": 0, "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice", "update_stock": 0, - "invoice_number": row.invoice_number + "invoice_number": row.invoice_number, + "disable_rounded_total": 1 }) accounting_dimension = get_accounting_dimensions() From 19a6c21eec788d9d688de209f1571fc94958f0bc Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Mon, 14 Feb 2022 19:43:26 +0530 Subject: [PATCH 103/112] refactor!: amazon mws integration (#29438) * remove: amazon mws integration * add patch to remove doctype * remove old patch --- .../doctype/amazon_mws_settings/__init__.py | 0 .../amazon_mws_settings/amazon_methods.py | 524 -------------- .../amazon_mws_settings/amazon_mws_api.py | 651 ------------------ .../amazon_mws_settings.js | 2 - .../amazon_mws_settings.json | 237 ------- .../amazon_mws_settings.py | 46 -- .../test_amazon_mws_settings.py | 8 - .../doctype/amazon_mws_settings/xml_utils.py | 104 --- .../erpnext_integrations.json | 11 - erpnext/hooks.py | 1 - erpnext/patches.txt | 1 + .../v12_0/rename_mws_settings_fields.py | 12 - .../v14_0/delete_amazon_mws_doctype.py | 5 + 13 files changed, 6 insertions(+), 1596 deletions(-) delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py delete mode 100755 erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py delete mode 100644 erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py delete mode 100644 erpnext/patches/v12_0/rename_mws_settings_fields.py create mode 100644 erpnext/patches/v14_0/delete_amazon_mws_doctype.py diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py deleted file mode 100644 index 29bc36f384..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ /dev/null @@ -1,524 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt - - -import csv -import math -import time -from io import StringIO - -import dateutil -import frappe -from frappe import _ - -import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws - - -#Get and Create Products -def get_products_details(): - products = get_products_instance() - reports = get_reports_instance() - - mws_settings = frappe.get_doc("Amazon MWS Settings") - market_place_list = return_as_list(mws_settings.market_place_id) - - for marketplace in market_place_list: - report_id = request_and_fetch_report_id("_GET_FLAT_FILE_OPEN_LISTINGS_DATA_", None, None, market_place_list) - - if report_id: - listings_response = reports.get_report(report_id=report_id) - - #Get ASIN Codes - string_io = StringIO(frappe.safe_decode(listings_response.original)) - csv_rows = list(csv.reader(string_io, delimiter='\t')) - asin_list = list(set([row[1] for row in csv_rows[1:]])) - #break into chunks of 10 - asin_chunked_list = list(chunks(asin_list, 10)) - - #Map ASIN Codes to SKUs - sku_asin = [{"asin":row[1],"sku":row[0]} for row in csv_rows[1:]] - - #Fetch Products List from ASIN - for asin_list in asin_chunked_list: - products_response = call_mws_method(products.get_matching_product,marketplaceid=marketplace, - asins=asin_list) - - matching_products_list = products_response.parsed - for product in matching_products_list: - skus = [row["sku"] for row in sku_asin if row["asin"]==product.ASIN] - for sku in skus: - create_item_code(product, sku) - -def get_products_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - products = mws.Products( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region = mws_settings.region, - domain = mws_settings.domain - ) - - return products - -def get_reports_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - reports = mws.Reports( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region = mws_settings.region, - domain = mws_settings.domain - ) - - return reports - -#returns list as expected by amazon API -def return_as_list(input_value): - if isinstance(input_value, list): - return input_value - else: - return [input_value] - -#function to chunk product data -def chunks(l, n): - for i in range(0, len(l), n): - yield l[i:i+n] - -def request_and_fetch_report_id(report_type, start_date=None, end_date=None, marketplaceids=None): - reports = get_reports_instance() - report_response = reports.request_report(report_type=report_type, - start_date=start_date, - end_date=end_date, - marketplaceids=marketplaceids) - - report_request_id = report_response.parsed["ReportRequestInfo"]["ReportRequestId"]["value"] - generated_report_id = None - #poll to get generated report - for x in range(1,10): - report_request_list_response = reports.get_report_request_list(requestids=[report_request_id]) - report_status = report_request_list_response.parsed["ReportRequestInfo"]["ReportProcessingStatus"]["value"] - - if report_status == "_SUBMITTED_" or report_status == "_IN_PROGRESS_": - #add time delay to wait for amazon to generate report - time.sleep(15) - continue - elif report_status == "_CANCELLED_": - break - elif report_status == "_DONE_NO_DATA_": - break - elif report_status == "_DONE_": - generated_report_id = report_request_list_response.parsed["ReportRequestInfo"]["GeneratedReportId"]["value"] - break - return generated_report_id - -def call_mws_method(mws_method, *args, **kwargs): - - mws_settings = frappe.get_doc("Amazon MWS Settings") - max_retries = mws_settings.max_retry_limit - - for x in range(0, max_retries): - try: - response = mws_method(*args, **kwargs) - return response - except Exception as e: - delay = math.pow(4, x) * 125 - frappe.log_error(message=e, title=f'Method "{mws_method.__name__}" failed') - time.sleep(delay) - continue - - mws_settings.enable_sync = 0 - mws_settings.save() - - frappe.throw(_("Sync has been temporarily disabled because maximum retries have been exceeded")) - -def create_item_code(amazon_item_json, sku): - if frappe.db.get_value("Item", sku): - return - - item = frappe.new_doc("Item") - - new_manufacturer = create_manufacturer(amazon_item_json) - new_brand = create_brand(amazon_item_json) - - mws_settings = frappe.get_doc("Amazon MWS Settings") - - item.item_code = sku - item.amazon_item_code = amazon_item_json.ASIN - item.item_group = mws_settings.item_group - item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title - item.brand = new_brand - item.manufacturer = new_manufacturer - - item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL - - temp_item_group = amazon_item_json.Product.AttributeSets.ItemAttributes.ProductGroup - - item_group = frappe.db.get_value("Item Group",filters={"item_group_name": temp_item_group}) - - if not item_group: - igroup = frappe.new_doc("Item Group") - igroup.item_group_name = temp_item_group - igroup.parent_item_group = mws_settings.item_group - igroup.insert() - - item.append("item_defaults", {'company':mws_settings.company}) - - item.insert(ignore_permissions=True) - create_item_price(amazon_item_json, item.item_code) - - return item.name - -def create_manufacturer(amazon_item_json): - if not amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer: - return None - - existing_manufacturer = frappe.db.get_value("Manufacturer", - filters={"short_name":amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer}) - - if not existing_manufacturer: - manufacturer = frappe.new_doc("Manufacturer") - manufacturer.short_name = amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer - manufacturer.insert() - return manufacturer.short_name - else: - return existing_manufacturer - -def create_brand(amazon_item_json): - if not amazon_item_json.Product.AttributeSets.ItemAttributes.Brand: - return None - - existing_brand = frappe.db.get_value("Brand", - filters={"brand":amazon_item_json.Product.AttributeSets.ItemAttributes.Brand}) - if not existing_brand: - brand = frappe.new_doc("Brand") - brand.brand = amazon_item_json.Product.AttributeSets.ItemAttributes.Brand - brand.insert() - return brand.brand - else: - return existing_brand - -def create_item_price(amazon_item_json, item_code): - item_price = frappe.new_doc("Item Price") - item_price.price_list = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "price_list") - if not("ListPrice" in amazon_item_json.Product.AttributeSets.ItemAttributes): - item_price.price_list_rate = 0 - else: - item_price.price_list_rate = amazon_item_json.Product.AttributeSets.ItemAttributes.ListPrice.Amount - - item_price.item_code = item_code - item_price.insert() - -#Get and create Orders -def get_orders(after_date): - try: - orders = get_orders_instance() - statuses = ["PartiallyShipped", "Unshipped", "Shipped", "Canceled"] - mws_settings = frappe.get_doc("Amazon MWS Settings") - market_place_list = return_as_list(mws_settings.market_place_id) - - orders_response = call_mws_method(orders.list_orders, marketplaceids=market_place_list, - fulfillment_channels=["MFN", "AFN"], - lastupdatedafter=after_date, - orderstatus=statuses, - max_results='50') - - while True: - orders_list = [] - - if "Order" in orders_response.parsed.Orders: - orders_list = return_as_list(orders_response.parsed.Orders.Order) - - if len(orders_list) == 0: - break - - for order in orders_list: - create_sales_order(order, after_date) - - if not "NextToken" in orders_response.parsed: - break - - next_token = orders_response.parsed.NextToken - orders_response = call_mws_method(orders.list_orders_by_next_token, next_token) - - except Exception as e: - frappe.log_error(title="get_orders", message=e) - -def get_orders_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - orders = mws.Orders( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region= mws_settings.region, - domain= mws_settings.domain, - version="2013-09-01" - ) - - return orders - -def create_sales_order(order_json,after_date): - customer_name = create_customer(order_json) - create_address(order_json, customer_name) - - market_place_order_id = order_json.AmazonOrderId - - so = frappe.db.get_value("Sales Order", - filters={"amazon_order_id": market_place_order_id}, - fieldname="name") - - taxes_and_charges = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "taxes_charges") - - if so: - return - - if not so: - items = get_order_items(market_place_order_id) - delivery_date = dateutil.parser.parse(order_json.LatestShipDate).strftime("%Y-%m-%d") - transaction_date = dateutil.parser.parse(order_json.PurchaseDate).strftime("%Y-%m-%d") - - so = frappe.get_doc({ - "doctype": "Sales Order", - "naming_series": "SO-", - "amazon_order_id": market_place_order_id, - "marketplace_id": order_json.MarketplaceId, - "customer": customer_name, - "delivery_date": delivery_date, - "transaction_date": transaction_date, - "items": items, - "company": frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "company") - }) - - try: - if taxes_and_charges: - charges_and_fees = get_charges_and_fees(market_place_order_id) - for charge in charges_and_fees.get("charges"): - so.append('taxes', charge) - - for fee in charges_and_fees.get("fees"): - so.append('taxes', fee) - - so.insert(ignore_permissions=True) - so.submit() - - except Exception as e: - import traceback - frappe.log_error(message=traceback.format_exc(), title="Create Sales Order") - -def create_customer(order_json): - order_customer_name = "" - - if not("BuyerName" in order_json): - order_customer_name = "Buyer - " + order_json.AmazonOrderId - else: - order_customer_name = order_json.BuyerName - - existing_customer_name = frappe.db.get_value("Customer", - filters={"name": order_customer_name}, fieldname="name") - - if existing_customer_name: - filters = [ - ["Dynamic Link", "link_doctype", "=", "Customer"], - ["Dynamic Link", "link_name", "=", existing_customer_name], - ["Dynamic Link", "parenttype", "=", "Contact"] - ] - - existing_contacts = frappe.get_list("Contact", filters) - - if existing_contacts: - pass - else: - new_contact = frappe.new_doc("Contact") - new_contact.first_name = order_customer_name - new_contact.append('links', { - "link_doctype": "Customer", - "link_name": existing_customer_name - }) - new_contact.insert() - - return existing_customer_name - else: - mws_customer_settings = frappe.get_doc("Amazon MWS Settings") - new_customer = frappe.new_doc("Customer") - new_customer.customer_name = order_customer_name - new_customer.customer_group = mws_customer_settings.customer_group - new_customer.territory = mws_customer_settings.territory - new_customer.customer_type = mws_customer_settings.customer_type - new_customer.save() - - new_contact = frappe.new_doc("Contact") - new_contact.first_name = order_customer_name - new_contact.append('links', { - "link_doctype": "Customer", - "link_name": new_customer.name - }) - - new_contact.insert() - - return new_customer.name - -def create_address(amazon_order_item_json, customer_name): - - filters = [ - ["Dynamic Link", "link_doctype", "=", "Customer"], - ["Dynamic Link", "link_name", "=", customer_name], - ["Dynamic Link", "parenttype", "=", "Address"] - ] - - existing_address = frappe.get_list("Address", filters) - - if not("ShippingAddress" in amazon_order_item_json): - return None - else: - make_address = frappe.new_doc("Address") - - if "AddressLine1" in amazon_order_item_json.ShippingAddress: - make_address.address_line1 = amazon_order_item_json.ShippingAddress.AddressLine1 - else: - make_address.address_line1 = "Not Provided" - - if "City" in amazon_order_item_json.ShippingAddress: - make_address.city = amazon_order_item_json.ShippingAddress.City - else: - make_address.city = "Not Provided" - - if "StateOrRegion" in amazon_order_item_json.ShippingAddress: - make_address.state = amazon_order_item_json.ShippingAddress.StateOrRegion - - if "PostalCode" in amazon_order_item_json.ShippingAddress: - make_address.pincode = amazon_order_item_json.ShippingAddress.PostalCode - - for address in existing_address: - address_doc = frappe.get_doc("Address", address["name"]) - if (address_doc.address_line1 == make_address.address_line1 and - address_doc.pincode == make_address.pincode): - return address - - make_address.append("links", { - "link_doctype": "Customer", - "link_name": customer_name - }) - make_address.address_type = "Shipping" - make_address.insert() - -def get_order_items(market_place_order_id): - mws_orders = get_orders_instance() - - order_items_response = call_mws_method(mws_orders.list_order_items, amazon_order_id=market_place_order_id) - final_order_items = [] - - order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem) - - warehouse = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "warehouse") - - while True: - for order_item in order_items_list: - - if not "ItemPrice" in order_item: - price = 0 - else: - price = order_item.ItemPrice.Amount - - final_order_items.append({ - "item_code": get_item_code(order_item), - "item_name": order_item.SellerSKU, - "description": order_item.Title, - "rate": price, - "qty": order_item.QuantityOrdered, - "stock_uom": "Nos", - "warehouse": warehouse, - "conversion_factor": "1.0" - }) - - if not "NextToken" in order_items_response.parsed: - break - - next_token = order_items_response.parsed.NextToken - - order_items_response = call_mws_method(mws_orders.list_order_items_by_next_token, next_token) - order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem) - - return final_order_items - -def get_item_code(order_item): - sku = order_item.SellerSKU - item_code = frappe.db.get_value("Item", {"item_code": sku}, "item_code") - if item_code: - return item_code - -def get_charges_and_fees(market_place_order_id): - finances = get_finances_instance() - - charges_fees = {"charges":[], "fees":[]} - - response = call_mws_method(finances.list_financial_events, amazon_order_id=market_place_order_id) - - shipment_event_list = return_as_list(response.parsed.FinancialEvents.ShipmentEventList) - - for shipment_event in shipment_event_list: - if shipment_event: - shipment_item_list = return_as_list(shipment_event.ShipmentEvent.ShipmentItemList.ShipmentItem) - - for shipment_item in shipment_item_list: - charges, fees = [], [] - - if 'ItemChargeList' in shipment_item.keys(): - charges = return_as_list(shipment_item.ItemChargeList.ChargeComponent) - - if 'ItemFeeList' in shipment_item.keys(): - fees = return_as_list(shipment_item.ItemFeeList.FeeComponent) - - for charge in charges: - if(charge.ChargeType != "Principal") and float(charge.ChargeAmount.CurrencyAmount) != 0: - charge_account = get_account(charge.ChargeType) - charges_fees.get("charges").append({ - "charge_type":"Actual", - "account_head": charge_account, - "tax_amount": charge.ChargeAmount.CurrencyAmount, - "description": charge.ChargeType + " for " + shipment_item.SellerSKU - }) - - for fee in fees: - if float(fee.FeeAmount.CurrencyAmount) != 0: - fee_account = get_account(fee.FeeType) - charges_fees.get("fees").append({ - "charge_type":"Actual", - "account_head": fee_account, - "tax_amount": fee.FeeAmount.CurrencyAmount, - "description": fee.FeeType + " for " + shipment_item.SellerSKU - }) - - return charges_fees - -def get_finances_instance(): - - mws_settings = frappe.get_doc("Amazon MWS Settings") - - finances = mws.Finances( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region= mws_settings.region, - domain= mws_settings.domain, - version="2015-05-01" - ) - - return finances - -def get_account(name): - existing_account = frappe.db.get_value("Account", {"account_name": "Amazon {0}".format(name)}) - account_name = existing_account - mws_settings = frappe.get_doc("Amazon MWS Settings") - - if not existing_account: - try: - new_account = frappe.new_doc("Account") - new_account.account_name = "Amazon {0}".format(name) - new_account.company = mws_settings.company - new_account.parent_account = mws_settings.market_place_account_group - new_account.insert(ignore_permissions=True) - account_name = new_account.name - except Exception as e: - frappe.log_error(message=e, title="Create Account") - - return account_name diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py deleted file mode 100755 index 4caf137455..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py +++ /dev/null @@ -1,651 +0,0 @@ -#!/usr/bin/env python -# -# Basic interface to Amazon MWS -# Based on http://code.google.com/p/amazon-mws-python -# Extended to include finances object - -import base64 -import hashlib -import hmac -import re -from urllib.parse import quote - -from erpnext.erpnext_integrations.doctype.amazon_mws_settings import xml_utils - -try: - from xml.etree.ElementTree import ParseError as XMLError -except ImportError: - from xml.parsers.expat import ExpatError as XMLError - -from time import gmtime, strftime - -from requests import request -from requests.exceptions import HTTPError - -__all__ = [ - 'Feeds', - 'Inventory', - 'MWSError', - 'Reports', - 'Orders', - 'Products', - 'Recommendations', - 'Sellers', - 'Finances' -] - -# See https://images-na.ssl-images-amazon.com/images/G/01/mwsportal/doc/en_US/bde/MWSDeveloperGuide._V357736853_.pdf page 8 -# for a list of the end points and marketplace IDs - -MARKETPLACES = { - "CA": "https://mws.amazonservices.ca", #A2EUQ1WTGCTBG2 - "US": "https://mws.amazonservices.com", #ATVPDKIKX0DER", - "DE": "https://mws-eu.amazonservices.com", #A1PA6795UKMFR9 - "ES": "https://mws-eu.amazonservices.com", #A1RKKUPIHCS9HS - "FR": "https://mws-eu.amazonservices.com", #A13V1IB3VIYZZH - "IN": "https://mws.amazonservices.in", #A21TJRUUN4KGV - "IT": "https://mws-eu.amazonservices.com", #APJ6JRA9NG5V4 - "UK": "https://mws-eu.amazonservices.com", #A1F83G8C2ARO7P - "JP": "https://mws.amazonservices.jp", #A1VC38T7YXB528 - "CN": "https://mws.amazonservices.com.cn", #AAHKV2X7AFYLW - "AE": " https://mws.amazonservices.ae", #A2VIGQ35RCS4UG - "MX": "https://mws.amazonservices.com.mx", #A1AM78C64UM0Y8 - "BR": "https://mws.amazonservices.com", #A2Q3Y263D00KWC -} - - -class MWSError(Exception): - """ - Main MWS Exception class - """ - # Allows quick access to the response object. - # Do not rely on this attribute, always check if its not None. - response = None - -def calc_md5(string): - """Calculates the MD5 encryption for the given string - """ - md = hashlib.md5() - md.update(string) - return base64.encodebytes(md.digest()).decode().strip() - - - -def remove_empty(d): - """ - Helper function that removes all keys from a dictionary (d), - that have an empty value. - """ - for key in list(d): - if not d[key]: - del d[key] - return d - -def remove_namespace(xml): - xml = xml.decode('utf-8') - regex = re.compile(' xmlns(:ns2)?="[^"]+"|(ns2:)|(xml:)') - return regex.sub('', xml) - -class DictWrapper(object): - def __init__(self, xml, rootkey=None): - self.original = xml - self._rootkey = rootkey - self._mydict = xml_utils.xml2dict().fromstring(remove_namespace(xml)) - self._response_dict = self._mydict.get(list(self._mydict)[0], self._mydict) - - @property - def parsed(self): - if self._rootkey: - return self._response_dict.get(self._rootkey) - else: - return self._response_dict - -class DataWrapper(object): - """ - Text wrapper in charge of validating the hash sent by Amazon. - """ - def __init__(self, data, header): - self.original = data - if 'content-md5' in header: - hash_ = calc_md5(self.original) - if header['content-md5'] != hash_: - raise MWSError("Wrong Contentlength, maybe amazon error...") - - @property - def parsed(self): - return self.original - -class MWS(object): - """ Base Amazon API class """ - - # This is used to post/get to the different uris used by amazon per api - # ie. /Orders/2011-01-01 - # All subclasses must define their own URI only if needed - URI = "/" - - # The API version varies in most amazon APIs - VERSION = "2009-01-01" - - # There seem to be some xml namespace issues. therefore every api subclass - # is recommended to define its namespace, so that it can be referenced - # like so AmazonAPISubclass.NS. - # For more information see http://stackoverflow.com/a/8719461/389453 - NS = '' - - # Some APIs are available only to either a "Merchant" or "Seller" - # the type of account needs to be sent in every call to the amazon MWS. - # This constant defines the exact name of the parameter Amazon expects - # for the specific API being used. - # All subclasses need to define this if they require another account type - # like "Merchant" in which case you define it like so. - # ACCOUNT_TYPE = "Merchant" - # Which is the name of the parameter for that specific account type. - ACCOUNT_TYPE = "SellerId" - - def __init__(self, access_key, secret_key, account_id, region='US', domain='', uri="", version=""): - self.access_key = access_key - self.secret_key = secret_key - self.account_id = account_id - self.version = version or self.VERSION - self.uri = uri or self.URI - - if domain: - self.domain = domain - elif region in MARKETPLACES: - self.domain = MARKETPLACES[region] - else: - error_msg = "Incorrect region supplied ('%(region)s'). Must be one of the following: %(marketplaces)s" % { - "marketplaces" : ', '.join(MARKETPLACES.keys()), - "region" : region, - } - raise MWSError(error_msg) - - def make_request(self, extra_data, method="GET", **kwargs): - """Make request to Amazon MWS API with these parameters - """ - - # Remove all keys with an empty value because - # Amazon's MWS does not allow such a thing. - extra_data = remove_empty(extra_data) - - params = { - 'AWSAccessKeyId': self.access_key, - self.ACCOUNT_TYPE: self.account_id, - 'SignatureVersion': '2', - 'Timestamp': self.get_timestamp(), - 'Version': self.version, - 'SignatureMethod': 'HmacSHA256', - } - params.update(extra_data) - request_description = '&'.join(['%s=%s' % (k, quote(params[k], safe='-_.~')) for k in sorted(params)]) - signature = self.calc_signature(method, request_description) - url = '%s%s?%s&Signature=%s' % (self.domain, self.uri, request_description, quote(signature)) - headers = {'User-Agent': 'python-amazon-mws/0.0.1 (Language=Python)'} - headers.update(kwargs.get('extra_headers', {})) - - try: - # Some might wonder as to why i don't pass the params dict as the params argument to request. - # My answer is, here i have to get the url parsed string of params in order to sign it, so - # if i pass the params dict as params to request, request will repeat that step because it will need - # to convert the dict to a url parsed string, so why do it twice if i can just pass the full url :). - response = request(method, url, data=kwargs.get('body', ''), headers=headers) - response.raise_for_status() - # When retrieving data from the response object, - # be aware that response.content returns the content in bytes while response.text calls - # response.content and converts it to unicode. - data = response.content - - # I do not check the headers to decide which content structure to server simply because sometimes - # Amazon's MWS API returns XML error responses with "text/plain" as the Content-Type. - try: - parsed_response = DictWrapper(data, extra_data.get("Action") + "Result") - except XMLError: - parsed_response = DataWrapper(data, response.headers) - - except HTTPError as e: - error = MWSError(str(e)) - error.response = e.response - raise error - - # Store the response object in the parsed_response for quick access - parsed_response.response = response - return parsed_response - - def get_service_status(self): - """ - Returns a GREEN, GREEN_I, YELLOW or RED status. - Depending on the status/availability of the API its being called from. - """ - - return self.make_request(extra_data=dict(Action='GetServiceStatus')) - - def calc_signature(self, method, request_description): - """Calculate MWS signature to interface with Amazon - """ - sig_data = method + '\n' + self.domain.replace('https://', '').lower() + '\n' + self.uri + '\n' + request_description - sig_data = sig_data.encode('utf-8') - secret_key = self.secret_key.encode('utf-8') - digest = hmac.new(secret_key, sig_data, hashlib.sha256).digest() - return base64.b64encode(digest).decode('utf-8') - - def get_timestamp(self): - """ - Returns the current timestamp in proper format. - """ - return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) - - def enumerate_param(self, param, values): - """ - Builds a dictionary of an enumerated parameter. - Takes any iterable and returns a dictionary. - ie. - enumerate_param('MarketplaceIdList.Id', (123, 345, 4343)) - returns - { - MarketplaceIdList.Id.1: 123, - MarketplaceIdList.Id.2: 345, - MarketplaceIdList.Id.3: 4343 - } - """ - params = {} - if values is not None: - if not param.endswith('.'): - param = "%s." % param - for num, value in enumerate(values): - params['%s%d' % (param, (num + 1))] = value - return params - - -class Feeds(MWS): - """ Amazon MWS Feeds API """ - - ACCOUNT_TYPE = "Merchant" - - def submit_feed(self, feed, feed_type, marketplaceids=None, - content_type="text/xml", purge='false'): - """ - Uploads a feed ( xml or .tsv ) to the seller's inventory. - Can be used for creating/updating products on Amazon. - """ - data = dict(Action='SubmitFeed', - FeedType=feed_type, - PurgeAndReplace=purge) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) - md = calc_md5(feed) - return self.make_request(data, method="POST", body=feed, - extra_headers={'Content-MD5': md, 'Content-Type': content_type}) - - def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None, - processingstatuses=None, fromdate=None, todate=None): - """ - Returns a list of all feed submissions submitted in the previous 90 days. - That match the query parameters. - """ - - data = dict(Action='GetFeedSubmissionList', - MaxCount=max_count, - SubmittedFromDate=fromdate, - SubmittedToDate=todate,) - data.update(self.enumerate_param('FeedSubmissionIdList.Id', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_submission_list_by_next_token(self, token): - data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token) - return self.make_request(data) - - def get_feed_submission_count(self, feedtypes=None, processingstatuses=None, fromdate=None, todate=None): - data = dict(Action='GetFeedSubmissionCount', - SubmittedFromDate=fromdate, - SubmittedToDate=todate) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def cancel_feed_submissions(self, feedids=None, feedtypes=None, fromdate=None, todate=None): - data = dict(Action='CancelFeedSubmissions', - SubmittedFromDate=fromdate, - SubmittedToDate=todate) - data.update(self.enumerate_param('FeedSubmissionIdList.Id.', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - return self.make_request(data) - - def get_feed_submission_result(self, feedid): - data = dict(Action='GetFeedSubmissionResult', FeedSubmissionId=feedid) - return self.make_request(data) - -class Reports(MWS): - """ Amazon MWS Reports API """ - - ACCOUNT_TYPE = "Merchant" - - ## REPORTS ### - - def get_report(self, report_id): - data = dict(Action='GetReport', ReportId=report_id) - return self.make_request(data) - - def get_report_count(self, report_types=(), acknowledged=None, fromdate=None, todate=None): - data = dict(Action='GetReportCount', - Acknowledged=acknowledged, - AvailableFromDate=fromdate, - AvailableToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) - return self.make_request(data) - - def get_report_list(self, requestids=(), max_count=None, types=(), acknowledged=None, - fromdate=None, todate=None): - data = dict(Action='GetReportList', - Acknowledged=acknowledged, - AvailableFromDate=fromdate, - AvailableToDate=todate, - MaxCount=max_count) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - def get_report_list_by_next_token(self, token): - data = dict(Action='GetReportListByNextToken', NextToken=token) - return self.make_request(data) - - def get_report_request_count(self, report_types=(), processingstatuses=(), fromdate=None, todate=None): - data = dict(Action='GetReportRequestCount', - RequestedFromDate=fromdate, - RequestedToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_report_request_list(self, requestids=(), types=(), processingstatuses=(), - max_count=None, fromdate=None, todate=None): - data = dict(Action='GetReportRequestList', - MaxCount=max_count, - RequestedFromDate=fromdate, - RequestedToDate=todate) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_report_request_list_by_next_token(self, token): - data = dict(Action='GetReportRequestListByNextToken', NextToken=token) - return self.make_request(data) - - def request_report(self, report_type, start_date=None, end_date=None, marketplaceids=()): - data = dict(Action='RequestReport', - ReportType=report_type, - StartDate=start_date, - EndDate=end_date) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) - return self.make_request(data) - - ### ReportSchedule ### - - def get_report_schedule_list(self, types=()): - data = dict(Action='GetReportScheduleList') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - def get_report_schedule_count(self, types=()): - data = dict(Action='GetReportScheduleCount') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - -class Orders(MWS): - """ Amazon Orders API """ - - URI = "/Orders/2013-09-01" - VERSION = "2013-09-01" - NS = '{https://mws.amazonservices.com/Orders/2011-01-01}' - - def list_orders(self, marketplaceids, created_after=None, created_before=None, lastupdatedafter=None, - lastupdatedbefore=None, orderstatus=(), fulfillment_channels=(), - payment_methods=(), buyer_email=None, seller_orderid=None, max_results='100'): - - data = dict(Action='ListOrders', - CreatedAfter=created_after, - CreatedBefore=created_before, - LastUpdatedAfter=lastupdatedafter, - LastUpdatedBefore=lastupdatedbefore, - BuyerEmail=buyer_email, - SellerOrderId=seller_orderid, - MaxResultsPerPage=max_results, - ) - data.update(self.enumerate_param('OrderStatus.Status.', orderstatus)) - data.update(self.enumerate_param('MarketplaceId.Id.', marketplaceids)) - data.update(self.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels)) - data.update(self.enumerate_param('PaymentMethod.Method.', payment_methods)) - return self.make_request(data) - - def list_orders_by_next_token(self, token): - data = dict(Action='ListOrdersByNextToken', NextToken=token) - return self.make_request(data) - - def get_order(self, amazon_order_ids): - data = dict(Action='GetOrder') - data.update(self.enumerate_param('AmazonOrderId.Id.', amazon_order_ids)) - return self.make_request(data) - - def list_order_items(self, amazon_order_id): - data = dict(Action='ListOrderItems', AmazonOrderId=amazon_order_id) - return self.make_request(data) - - def list_order_items_by_next_token(self, token): - data = dict(Action='ListOrderItemsByNextToken', NextToken=token) - return self.make_request(data) - - -class Products(MWS): - """ Amazon MWS Products API """ - - URI = '/Products/2011-10-01' - VERSION = '2011-10-01' - NS = '{http://mws.amazonservices.com/schema/Products/2011-10-01}' - - def list_matching_products(self, marketplaceid, query, contextid=None): - """ Returns a list of products and their attributes, ordered by - relevancy, based on a search query that you specify. - Your search query can be a phrase that describes the product - or it can be a product identifier such as a UPC, EAN, ISBN, or JAN. - """ - data = dict(Action='ListMatchingProducts', - MarketplaceId=marketplaceid, - Query=query, - QueryContextId=contextid) - return self.make_request(data) - - def get_matching_product(self, marketplaceid, asins): - """ Returns a list of products and their attributes, based on a list of - ASIN values that you specify. - """ - data = dict(Action='GetMatchingProduct', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_matching_product_for_id(self, marketplaceid, type, id): - """ Returns a list of products and their attributes, based on a list of - product identifier values (asin, sellersku, upc, ean, isbn and JAN) - Added in Fourth Release, API version 2011-10-01 - """ - data = dict(Action='GetMatchingProductForId', - MarketplaceId=marketplaceid, - IdType=type) - data.update(self.enumerate_param('IdList.Id', id)) - return self.make_request(data) - - def get_competitive_pricing_for_sku(self, marketplaceid, skus): - """ Returns the current competitive pricing of a product, - based on the SellerSKU and MarketplaceId that you specify. - """ - data = dict(Action='GetCompetitivePricingForSKU', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_competitive_pricing_for_asin(self, marketplaceid, asins): - """ Returns the current competitive pricing of a product, - based on the ASIN and MarketplaceId that you specify. - """ - data = dict(Action='GetCompetitivePricingForASIN', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any", excludeme="False"): - data = dict(Action='GetLowestOfferListingsForSKU', - MarketplaceId=marketplaceid, - ItemCondition=condition, - ExcludeMe=excludeme) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="Any", excludeme="False"): - data = dict(Action='GetLowestOfferListingsForASIN', - MarketplaceId=marketplaceid, - ItemCondition=condition, - ExcludeMe=excludeme) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_product_categories_for_sku(self, marketplaceid, sku): - data = dict(Action='GetProductCategoriesForSKU', - MarketplaceId=marketplaceid, - SellerSKU=sku) - return self.make_request(data) - - def get_product_categories_for_asin(self, marketplaceid, asin): - data = dict(Action='GetProductCategoriesForASIN', - MarketplaceId=marketplaceid, - ASIN=asin) - return self.make_request(data) - - def get_my_price_for_sku(self, marketplaceid, skus, condition=None): - data = dict(Action='GetMyPriceForSKU', - MarketplaceId=marketplaceid, - ItemCondition=condition) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_my_price_for_asin(self, marketplaceid, asins, condition=None): - data = dict(Action='GetMyPriceForASIN', - MarketplaceId=marketplaceid, - ItemCondition=condition) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - -class Sellers(MWS): - """ Amazon MWS Sellers API """ - - URI = '/Sellers/2011-07-01' - VERSION = '2011-07-01' - NS = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}' - - def list_marketplace_participations(self): - """ - Returns a list of marketplaces a seller can participate in and - a list of participations that include seller-specific information in that marketplace. - The operation returns only those marketplaces where the seller's account is in an active state. - """ - - data = dict(Action='ListMarketplaceParticipations') - return self.make_request(data) - - def list_marketplace_participations_by_next_token(self, token): - """ - Takes a "NextToken" and returns the same information as "list_marketplace_participations". - Based on the "NextToken". - """ - data = dict(Action='ListMarketplaceParticipations', NextToken=token) - return self.make_request(data) - -#### Fulfillment APIs #### - -class InboundShipments(MWS): - URI = "/FulfillmentInboundShipment/2010-10-01" - VERSION = '2010-10-01' - - # To be completed - - -class Inventory(MWS): - """ Amazon MWS Inventory Fulfillment API """ - - URI = '/FulfillmentInventory/2010-10-01' - VERSION = '2010-10-01' - NS = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}" - - def list_inventory_supply(self, skus=(), datetime=None, response_group='Basic'): - """ Returns information on available inventory """ - - data = dict(Action='ListInventorySupply', - QueryStartDateTime=datetime, - ResponseGroup=response_group, - ) - data.update(self.enumerate_param('SellerSkus.member.', skus)) - return self.make_request(data, "POST") - - def list_inventory_supply_by_next_token(self, token): - data = dict(Action='ListInventorySupplyByNextToken', NextToken=token) - return self.make_request(data, "POST") - - -class OutboundShipments(MWS): - URI = "/FulfillmentOutboundShipment/2010-10-01" - VERSION = "2010-10-01" - # To be completed - - -class Recommendations(MWS): - - """ Amazon MWS Recommendations API """ - - URI = '/Recommendations/2013-04-01' - VERSION = '2013-04-01' - NS = "{https://mws.amazonservices.com/Recommendations/2013-04-01}" - - def get_last_updated_time_for_recommendations(self, marketplaceid): - """ - Checks whether there are active recommendations for each category for the given marketplace, and if there are, - returns the time when recommendations were last updated for each category. - """ - - data = dict(Action='GetLastUpdatedTimeForRecommendations', - MarketplaceId=marketplaceid) - return self.make_request(data, "POST") - - def list_recommendations(self, marketplaceid, recommendationcategory=None): - """ - Returns your active recommendations for a specific category or for all categories for a specific marketplace. - """ - - data = dict(Action="ListRecommendations", - MarketplaceId=marketplaceid, - RecommendationCategory=recommendationcategory) - return self.make_request(data, "POST") - - def list_recommendations_by_next_token(self, token): - """ - Returns the next page of recommendations using the NextToken parameter. - """ - - data = dict(Action="ListRecommendationsByNextToken", - NextToken=token) - return self.make_request(data, "POST") - -class Finances(MWS): - """ Amazon Finances API""" - URI = '/Finances/2015-05-01' - VERSION = '2015-05-01' - NS = "{https://mws.amazonservices.com/Finances/2015-05-01}" - - def list_financial_events(self , posted_after=None, posted_before=None, - amazon_order_id=None, max_results='100'): - - data = dict(Action='ListFinancialEvents', - PostedAfter=posted_after, - PostedBefore=posted_before, - AmazonOrderId=amazon_order_id, - MaxResultsPerPage=max_results, - ) - return self.make_request(data) diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js deleted file mode 100644 index f5ea8047c6..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js +++ /dev/null @@ -1,2 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json deleted file mode 100644 index 5a678e77d1..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "actions": [], - "creation": "2018-07-31 05:51:41.357047", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_amazon", - "mws_credentials", - "seller_id", - "aws_access_key_id", - "mws_auth_token", - "secret_key", - "column_break_4", - "market_place_id", - "region", - "domain", - "section_break_13", - "company", - "warehouse", - "item_group", - "price_list", - "column_break_17", - "customer_group", - "territory", - "customer_type", - "market_place_account_group", - "section_break_12", - "after_date", - "taxes_charges", - "sync_products", - "sync_orders", - "column_break_10", - "enable_sync", - "max_retry_limit" - ], - "fields": [ - { - "default": "0", - "fieldname": "enable_amazon", - "fieldtype": "Check", - "label": "Enable Amazon" - }, - { - "fieldname": "mws_credentials", - "fieldtype": "Section Break", - "label": "MWS Credentials" - }, - { - "fieldname": "seller_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Seller ID", - "reqd": 1 - }, - { - "fieldname": "aws_access_key_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "AWS Access Key ID", - "reqd": 1 - }, - { - "fieldname": "mws_auth_token", - "fieldtype": "Data", - "in_list_view": 1, - "label": "MWS Auth Token", - "reqd": 1 - }, - { - "fieldname": "secret_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Secret Key", - "reqd": 1 - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "market_place_id", - "fieldtype": "Data", - "label": "Market Place ID", - "reqd": 1 - }, - { - "fieldname": "region", - "fieldtype": "Select", - "label": "Region", - "options": "\nAE\nAU\nBR\nCA\nCN\nDE\nES\nFR\nIN\nJP\nIT\nMX\nUK\nUS", - "reqd": 1 - }, - { - "fieldname": "domain", - "fieldtype": "Data", - "label": "Domain", - "reqd": 1 - }, - { - "fieldname": "section_break_13", - "fieldtype": "Section Break" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company", - "reqd": 1 - }, - { - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "options": "Warehouse", - "reqd": 1 - }, - { - "fieldname": "item_group", - "fieldtype": "Link", - "label": "Item Group", - "options": "Item Group", - "reqd": 1 - }, - { - "fieldname": "price_list", - "fieldtype": "Link", - "label": "Price List", - "options": "Price List", - "reqd": 1 - }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, - { - "fieldname": "customer_group", - "fieldtype": "Link", - "label": "Customer Group", - "options": "Customer Group", - "reqd": 1 - }, - { - "fieldname": "territory", - "fieldtype": "Link", - "label": "Territory", - "options": "Territory", - "reqd": 1 - }, - { - "fieldname": "customer_type", - "fieldtype": "Select", - "label": "Customer Type", - "options": "Individual\nCompany", - "reqd": 1 - }, - { - "fieldname": "market_place_account_group", - "fieldtype": "Link", - "label": "Market Place Account Group", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "section_break_12", - "fieldtype": "Section Break" - }, - { - "description": "Amazon will synch data updated after this date", - "fieldname": "after_date", - "fieldtype": "Datetime", - "label": "After Date", - "reqd": 1 - }, - { - "default": "0", - "description": "Get financial breakup of Taxes and charges data by Amazon ", - "fieldname": "taxes_charges", - "fieldtype": "Check", - "label": "Sync Taxes and Charges" - }, - { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { - "default": "3", - "fieldname": "max_retry_limit", - "fieldtype": "Int", - "label": "Max Retry Limit" - }, - { - "description": "Always sync your products from Amazon MWS before synching the Orders details", - "fieldname": "sync_products", - "fieldtype": "Button", - "label": "Sync Products", - "options": "get_products_details" - }, - { - "description": "Click this button to pull your Sales Order data from Amazon MWS.", - "fieldname": "sync_orders", - "fieldtype": "Button", - "label": "Sync Orders", - "options": "get_order_details" - }, - { - "default": "0", - "description": "Check this to enable a scheduled Daily synchronization routine via scheduler", - "fieldname": "enable_sync", - "fieldtype": "Check", - "label": "Enable Scheduled Sync" - } - ], - "issingle": 1, - "links": [], - "modified": "2020-04-07 14:26:20.174848", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "Amazon MWS Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py deleted file mode 100644 index c1f460f49b..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt - - -import dateutil -import frappe -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.model.document import Document - -from erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods import get_orders - - -class AmazonMWSSettings(Document): - def validate(self): - if self.enable_amazon == 1: - self.enable_sync = 1 - setup_custom_fields() - else: - self.enable_sync = 0 - - @frappe.whitelist() - def get_products_details(self): - if self.enable_amazon == 1: - frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_products_details') - - @frappe.whitelist() - def get_order_details(self): - if self.enable_amazon == 1: - after_date = dateutil.parser.parse(self.after_date).strftime("%Y-%m-%d") - frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_orders', after_date=after_date) - -def schedule_get_order_details(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - if mws_settings.enable_sync and mws_settings.enable_amazon: - after_date = dateutil.parser.parse(mws_settings.after_date).strftime("%Y-%m-%d") - get_orders(after_date = after_date) - -def setup_custom_fields(): - custom_fields = { - "Item": [dict(fieldname='amazon_item_code', label='Amazon Item Code', - fieldtype='Data', insert_after='series', read_only=1, print_hide=1)], - "Sales Order": [dict(fieldname='amazon_order_id', label='Amazon Order ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1)] - } - - create_custom_fields(custom_fields) diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py deleted file mode 100644 index 4be7960ded..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestAmazonMWSSettings(unittest.TestCase): - pass diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py deleted file mode 100644 index d9dfc6f72d..0000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Created on Tue Jun 26 15:42:07 2012 - -Borrowed from https://github.com/timotheus/ebaysdk-python - -@author: pierre -""" - -import re -import xml.etree.ElementTree as ET - - -class object_dict(dict): - """object view of dict, you can - >>> a = object_dict() - >>> a.fish = 'fish' - >>> a['fish'] - 'fish' - >>> a['water'] = 'water' - >>> a.water - 'water' - >>> a.test = {'value': 1} - >>> a.test2 = object_dict({'name': 'test2', 'value': 2}) - >>> a.test, a.test2.name, a.test2.value - (1, 'test2', 2) - """ - def __init__(self, initd=None): - if initd is None: - initd = {} - dict.__init__(self, initd) - - def __getattr__(self, item): - - try: - d = self.__getitem__(item) - except KeyError: - return None - - if isinstance(d, dict) and 'value' in d and len(d) == 1: - return d['value'] - else: - return d - - # if value is the only key in object, you can omit it - def __setstate__(self, item): - return False - - def __setattr__(self, item, value): - self.__setitem__(item, value) - - def getvalue(self, item, value=None): - return self.get(item, {}).get('value', value) - - -class xml2dict(object): - - def __init__(self): - pass - - def _parse_node(self, node): - node_tree = object_dict() - # Save attrs and text, hope there will not be a child with same name - if node.text: - node_tree.value = node.text - for (k, v) in node.attrib.items(): - k, v = self._namespace_split(k, object_dict({'value':v})) - node_tree[k] = v - #Save childrens - for child in node.getchildren(): - tag, tree = self._namespace_split(child.tag, - self._parse_node(child)) - if tag not in node_tree: # the first time, so store it in dict - node_tree[tag] = tree - continue - old = node_tree[tag] - if not isinstance(old, list): - node_tree.pop(tag) - node_tree[tag] = [old] # multi times, so change old dict to a list - node_tree[tag].append(tree) # add the new one - - return node_tree - - def _namespace_split(self, tag, value): - """ - Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients' - ns = http://cs.sfsu.edu/csc867/myscheduler - name = patients - """ - result = re.compile(r"\{(.*)\}(.*)").search(tag) - if result: - value.namespace, tag = result.groups() - - return (tag, value) - - def parse(self, file): - """parse a xml file to a dict""" - f = open(file, 'r') - return self.fromstring(f.read()) - - def fromstring(self, s): - """parse a string""" - t = ET.fromstring(s) - root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t)) - return object_dict({root_tag: root_tree}) diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json index 45077aa66c..1f2619b9a6 100644 --- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json @@ -29,17 +29,6 @@ "onboard": 0, "type": "Link" }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Amazon MWS Settings", - "link_count": 0, - "link_to": "Amazon MWS Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d99f23ed64..38fa6916a5 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -333,7 +333,6 @@ scheduler_events = { "hourly": [ 'erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails', "erpnext.accounts.doctype.subscription.subscription.process_all", - "erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_settings.schedule_get_order_details", "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.hourly_reminder", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d300340671..d104bc003c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -351,3 +351,4 @@ erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.update_disbursement_account erpnext.patches.v13_0.update_reserved_qty_closed_wo +erpnext.patches.v14_0.delete_amazon_mws_doctype diff --git a/erpnext/patches/v12_0/rename_mws_settings_fields.py b/erpnext/patches/v12_0/rename_mws_settings_fields.py deleted file mode 100644 index d5bf38d204..0000000000 --- a/erpnext/patches/v12_0/rename_mws_settings_fields.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) 2020, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe - - -def execute(): - count = frappe.db.sql("SELECT COUNT(*) FROM `tabSingles` WHERE doctype='Amazon MWS Settings' AND field='enable_sync';")[0][0] - if count == 0: - frappe.db.sql("UPDATE `tabSingles` SET field='enable_sync' WHERE doctype='Amazon MWS Settings' AND field='enable_synch';") - - frappe.reload_doc("ERPNext Integrations", "doctype", "Amazon MWS Settings") diff --git a/erpnext/patches/v14_0/delete_amazon_mws_doctype.py b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py new file mode 100644 index 0000000000..525da6cbe5 --- /dev/null +++ b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py @@ -0,0 +1,5 @@ +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Amazon MWS Settings", ignore_missing=True) \ No newline at end of file From 2ff6b3560e6ec8820a6ba8cccba24945e089d7d2 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 14 Feb 2022 19:51:58 +0530 Subject: [PATCH 104/112] fix: Fixes in TDS payable monthly report --- .../tds_payable_monthly/tds_payable_monthly.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index caee1a10bb..9eeeb3a680 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -23,7 +23,7 @@ def validate_filters(filters): def get_result(filters, tds_docs, tds_accounts, tax_category_map): supplier_map = get_supplier_pan_map() tax_rate_map = get_tax_rate_map(filters) - gle_map = get_gle_map(filters, tds_docs) + gle_map = get_gle_map(tds_docs) out = [] for name, details in gle_map.items(): @@ -78,7 +78,7 @@ def get_supplier_pan_map(): return supplier_map -def get_gle_map(filters, documents): +def get_gle_map(documents): # create gle_map of the form # {"purchase_invoice": list of dict of all gle created for this invoice} gle_map = {} @@ -86,7 +86,7 @@ def get_gle_map(filters, documents): gle = frappe.db.get_all('GL Entry', { "voucher_no": ["in", documents], - "credit": (">", 0) + "is_cancelled": 0 }, ["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"], ) @@ -184,21 +184,25 @@ def get_tds_docs(filters): payment_entries = [] journal_entries = [] tax_category_map = {} + or_filters={} tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')}, pluck="account") query_filters = { - "credit": ('>', 0), "account": ("in", tds_accounts), "posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]), "is_cancelled": 0 } - if filters.get('supplier'): - query_filters.update({'against': filters.get('supplier')}) + if filters.get("supplier"): + del query_filters["account"] + or_filters = { + "against": filters.get('supplier'), + "party": filters.get('supplier') + } - tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"]) + tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"]) for d in tds_docs: if d.voucher_type == "Purchase Invoice": From f221a0d253c6c4a2dc1faf4b41f371bf5a7e86ad Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 14 Feb 2022 20:14:14 +0530 Subject: [PATCH 105/112] test: Cover back to back recos from different warehouses --- .../stock_ageing/stock_ageing_fifo_logic.md | 1 + .../report/stock_ageing/test_stock_ageing.py | 127 +++++++++++++++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md index 5ffe97fd74..9e9bed48e3 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md +++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md @@ -15,6 +15,7 @@ Here, the balance qty is 70. 50 qty is (today-the 1st) days old 20 qty is (today-the 2nd) days old +> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values. ### Calculation of FIFO Slots #### Case 1: Outward from sufficient balance qty diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 949bb7c15a..66d2f6b753 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -15,11 +15,12 @@ class TestStockAgeing(ERPNextTestCase): ) def test_normal_inward_outward_queue(self): - "Reference: Case 1 in stock_ageing_fifo_logic.md" + "Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)" sle = [ frappe._dict( name="Flask Item", actual_qty=30, qty_after_transaction=30, + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -27,6 +28,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=50, + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Entry", voucher_no="002", has_serial_no=False, serial_no=None @@ -34,6 +36,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=(-10), qty_after_transaction=40, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -50,11 +53,12 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(queue[0][0], 20.0) def test_insufficient_balance(self): - "Reference: Case 3 in stock_ageing_fifo_logic.md" + "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ frappe._dict( name="Flask Item", actual_qty=(-30), qty_after_transaction=(-30), + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -62,6 +66,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=(-10), + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Entry", voucher_no="002", has_serial_no=False, serial_no=None @@ -69,6 +74,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=10, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -76,6 +82,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=10, qty_after_transaction=20, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="004", has_serial_no=False, serial_no=None @@ -91,11 +98,16 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(queue[0][0], 10.0) self.assertEqual(queue[1][0], 10.0) - def test_stock_reconciliation(self): + def test_basic_stock_reconciliation(self): + """ + Ledger (same wh): [+30, reco reset >> 50, -10] + Bal: 40 + """ sle = [ frappe._dict( name="Flask Item", actual_qty=30, qty_after_transaction=30, + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -103,6 +115,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=0, qty_after_transaction=50, + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Reconciliation", voucher_no="002", has_serial_no=False, serial_no=None @@ -110,6 +123,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=(-10), qty_after_transaction=40, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -122,5 +136,112 @@ class TestStockAgeing(ERPNextTestCase): queue = result["fifo_queue"] self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(result["total_qty"], 40.0) self.assertEqual(queue[0][0], 20.0) self.assertEqual(queue[1][0], 20.0) + + def test_sequential_stock_reco_same_warehouse(self): + """ + Test back to back stock recos (same warehouse). + Ledger: [reco opening >> +1000, reco reset >> 400, -10] + Bal: 390 + """ + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=1000, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-02", voucher_type="Stock Reconciliation", + voucher_no="003", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=390, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, serial_no=None + ) + ] + slots = FIFOSlots(self.filters, sle).generate() + + result = slots["Flask Item"] + queue = result["fifo_queue"] + + self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(result["total_qty"], 390.0) + self.assertEqual(queue[0][0], 390.0) + + def test_sequential_stock_reco_different_warehouse(self): + """ + Ledger: + WH | Voucher | Qty + ------------------- + WH1 | Reco | 1000 + WH2 | Reco | 400 + WH1 | SE | -10 + + Bal: WH1 bal + WH2 bal = 990 + 400 = 1390 + """ + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=1000, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=400, + warehouse="WH 2", + posting_date="2021-12-02", voucher_type="Stock Reconciliation", + voucher_no="003", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=990, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, serial_no=None + ) + ] + + item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots( + filters=self.filters,sle=sle + ) + + # test without 'show_warehouse_wise_stock' + item_result = item_wise_slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 1390.0) + self.assertEqual(queue[0][0], 990.0) + self.assertEqual(queue[1][0], 400.0) + + # test with 'show_warehouse_wise_stock' checked + item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots] + self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"]) + +def generate_item_and_item_wh_wise_slots(filters, sle): + "Return results with and without 'show_warehouse_wise_stock'" + item_wise_slots = FIFOSlots(filters, sle).generate() + + filters.show_warehouse_wise_stock = True + item_wh_wise_slots = FIFOSlots(filters, sle).generate() + filters.show_warehouse_wise_stock = False + + return item_wise_slots, item_wh_wise_slots \ No newline at end of file From 04cbde2e52bc9839b8ce3d6446c870f9957b614d Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 14 Feb 2022 20:38:04 +0530 Subject: [PATCH 106/112] fix: Filter out bank payment entries --- .../report/tds_payable_monthly/tds_payable_monthly.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index 9eeeb3a680..57f79748f0 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -184,7 +184,8 @@ def get_tds_docs(filters): payment_entries = [] journal_entries = [] tax_category_map = {} - or_filters={} + or_filters = {} + bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name") tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')}, pluck="account") @@ -192,11 +193,13 @@ def get_tds_docs(filters): query_filters = { "account": ("in", tds_accounts), "posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]), - "is_cancelled": 0 + "is_cancelled": 0, + "against": ("not in", bank_accounts) } if filters.get("supplier"): del query_filters["account"] + del query_filters["against"] or_filters = { "against": filters.get('supplier'), "party": filters.get('supplier') From e46a1bc80fd2aaf01be4298af0d2b9e93fbdcd24 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 14 Feb 2022 21:00:51 +0530 Subject: [PATCH 107/112] fix: Server Tests and sider --- .../production_plan/test_production_plan.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 3aa5c9f008..afa1501efc 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -138,7 +138,7 @@ class TestProductionPlan(ERPNextTestCase): """ - Disable 'ignore_existing_ordered_qty'. - Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for - non exploded BOM. + non exploded BOM. """ sr1 = create_stock_reconciliation(item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=1, rate=130) @@ -506,11 +506,11 @@ class TestProductionPlan(ERPNextTestCase): ) make_stock_entry(item_code="Raw Material Item 1", - target="_Test Warehouse - _TC", + target="Work In Progress - _TC", qty=2, basic_rate=100 ) make_stock_entry(item_code="Raw Material Item 2", - target="_Test Warehouse - _TC", + target="Work In Progress - _TC", qty=2, basic_rate=100 ) @@ -554,6 +554,15 @@ class TestProductionPlan(ERPNextTestCase): make_stock_entry as make_se_from_wo, ) + make_stock_entry(item_code="Raw Material Item 1", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + make_stock_entry(item_code="Raw Material Item 2", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + pln = create_production_plan( item_code='Test Production Item 1', skip_getting_mr_items=True From 13a60fb2582aa7ed0de1888c6d5a6f111527784e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 15 Feb 2022 10:43:58 +0530 Subject: [PATCH 108/112] ci: ignore bugs identified in QA process for stalebot [skip ci] --- .github/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/stale.yml b/.github/stale.yml index 8b7cb9be3e..1c2dcf3ba9 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -30,6 +30,7 @@ issues: exemptLabels: - valid - to-validate + - QA markComment: > This issue has been automatically marked as inactive because it has not had recent activity and it wasn't validated by maintainer team. It will be From 1e9766433aa8d2033620ece039a65c1109bee612 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 15 Feb 2022 10:57:06 +0530 Subject: [PATCH 109/112] fix: get_item_details overrides parentype (#29799) --- erpnext/stock/get_item_details.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index d37dc7ad9e..9bec5f7494 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -6,6 +6,7 @@ import json import frappe from frappe import _, throw +from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision from frappe.utils import add_days, add_months, cint, cstr, flt, getdate @@ -119,8 +120,15 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.rate = args.rate or out.price_list_rate out.amount = flt(args.qty) * flt(out.rate) + out = remove_standard_fields(out) return out +def remove_standard_fields(details): + for key in child_table_fields + default_fields: + details.pop(key, None) + return details + + def update_stock(args, out): if (args.get("doctype") == "Delivery Note" or (args.get("doctype") == "Sales Invoice" and args.get('update_stock'))) \ From f89a64db486b46ac756d5ba62faee87f28baf889 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 15 Feb 2022 16:10:35 +0530 Subject: [PATCH 110/112] fix: dont attempt to set batch number if item doesn't have batch no (#29812) This causes other triggers and unnecessary changes (e.g. price list) --- erpnext/selling/sales_common.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 16e3847168..98131f96ed 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -227,11 +227,11 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran }, callback:function(r){ if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) { - if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return; - - me.set_batch_number(cdt, cdn); - me.batch_no(doc, cdt, cdn); + if (has_batch_no) { + me.set_batch_number(cdt, cdn); + me.batch_no(doc, cdt, cdn); + } } } }); From 08a391fa88d97ab003a00e58eb47fb263923adc1 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 16 Feb 2022 10:56:57 +0530 Subject: [PATCH 111/112] test: set correct DocType (#29819) --- .../test_supplier_scorecard.py | 8 ++-- .../doctype/salary_slip/test_salary_slip.py | 38 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py index 49e33517e6..7908c35cbb 100644 --- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py @@ -49,7 +49,7 @@ valid_scorecard = [ "min_grade":0.0,"name":"Very Poor", "prevent_rfqs":1, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":30.0, "prevent_pos":1, "warn_pos":0, @@ -65,7 +65,7 @@ valid_scorecard = [ "name":"Poor", "prevent_rfqs":1, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":50.0, "prevent_pos":0, "warn_pos":0, @@ -81,7 +81,7 @@ valid_scorecard = [ "name":"Average", "prevent_rfqs":0, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":80.0, "prevent_pos":0, "warn_pos":0, @@ -97,7 +97,7 @@ valid_scorecard = [ "name":"Excellent", "prevent_rfqs":0, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":100.0, "prevent_pos":0, "warn_pos":0, diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index f83053e12d..daa0f8952b 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -6,6 +6,7 @@ import random import unittest import frappe +from frappe.model.document import Document from frappe.utils import ( add_days, add_months, @@ -687,20 +688,25 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): def make_salary_component(salary_components, test_tax, company_list=None): for salary_component in salary_components: - if not frappe.db.exists('Salary Component', salary_component["salary_component"]): - if test_tax: - if salary_component["type"] == "Earning": - salary_component["is_tax_applicable"] = 1 - elif salary_component["salary_component"] == "TDS": - salary_component["variable_based_on_taxable_salary"] = 1 - salary_component["amount_based_on_formula"] = 0 - salary_component["amount"] = 0 - salary_component["formula"] = "" - salary_component["condition"] = "" - salary_component["doctype"] = "Salary Component" - salary_component["salary_component_abbr"] = salary_component["abbr"] - frappe.get_doc(salary_component).insert() - get_salary_component_account(salary_component["salary_component"], company_list) + if frappe.db.exists('Salary Component', salary_component["salary_component"]): + continue + + if test_tax: + if salary_component["type"] == "Earning": + salary_component["is_tax_applicable"] = 1 + elif salary_component["salary_component"] == "TDS": + salary_component["variable_based_on_taxable_salary"] = 1 + salary_component["amount_based_on_formula"] = 0 + salary_component["amount"] = 0 + salary_component["formula"] = "" + salary_component["condition"] = "" + + salary_component["salary_component_abbr"] = salary_component["abbr"] + doc = frappe.new_doc("Salary Component") + doc.update(salary_component) + doc.insert() + + get_salary_component_account(doc, company_list) def get_salary_component_account(sal_comp, company_list=None): company = erpnext.get_default_company() @@ -708,7 +714,9 @@ def get_salary_component_account(sal_comp, company_list=None): if company_list and company not in company_list: company_list.append(company) - sal_comp = frappe.get_doc("Salary Component", sal_comp) + if not isinstance(sal_comp, Document): + sal_comp = frappe.get_doc("Salary Component", sal_comp) + if not sal_comp.get("accounts"): for d in company_list: company_abbr = frappe.get_cached_value('Company', d, 'abbr') From a26183e205effa11d1fae7a3d6cb96c7db100e07 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira <33246109+kennethsequeira@users.noreply.github.com> Date: Wed, 16 Feb 2022 13:02:36 +0530 Subject: [PATCH 112/112] fix: add supported currencies (#29805) --- .../doctype/gocardless_settings/gocardless_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py index a8119ac86c..f02f76e18b 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -13,7 +13,7 @@ from frappe.utils import call_hook_method, cint, flt, get_url class GoCardlessSettings(Document): - supported_currencies = ["EUR", "DKK", "GBP", "SEK"] + supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"] def validate(self): self.initialize_client() @@ -80,7 +80,7 @@ class GoCardlessSettings(Document): def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: - frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency)) + frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency)) def get_payment_url(self, **kwargs): return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))