diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 54b381d7f89..30ca22aedc5 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -5,7 +5,6 @@ on: paths-ignore: - '**.js' - '**.md' - types: [opened, unlabeled, synchronize, reopened] workflow_dispatch: @@ -30,11 +29,6 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: - - name: Check for merge conficts label - if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }} - run: | - echo "Remove merge conflicts and remove conflict label to run CI" - exit 1 - name: Clone uses: actions/checkout@v2 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 1c9743c5700..c62622eecec 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -5,7 +5,6 @@ on: paths-ignore: - '**.js' - '**.md' - types: [opened, unlabeled, synchronize, reopened] workflow_dispatch: push: branches: [ develop ] @@ -40,12 +39,6 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: - - name: Check for merge conficts label - if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }} - run: | - echo "Remove merge conflicts and remove conflict label to run CI" - exit 1 - - name: Clone uses: actions/checkout@v2 diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index f3351ddcba4..317fcc02b50 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -232,7 +232,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers): }), transaction.currency, company_account) if total_amount > transaction.unallocated_amount: - frappe.throw(_("The Sum Total of Amounts of All Selected Vouchers Should be Less than the Unallocated Amount of the Bank Transaction")) + frappe.throw(_("The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction")) account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") for voucher in vouchers: diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index b8e8d970220..e3fc1dc73f8 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -109,8 +109,13 @@ def get_paid_amount(payment_entry, currency, bank_account): paid_amount_field = "paid_amount" if payment_entry.payment_document == 'Payment Entry': doc = frappe.get_doc("Payment Entry", payment_entry.payment_entry) - paid_amount_field = ("base_paid_amount" - if doc.paid_to_account_currency == currency else "paid_amount") + + if doc.payment_type == 'Receive': + paid_amount_field = ("received_amount" + if doc.paid_to_account_currency == currency else "base_received_amount") + elif doc.payment_type == 'Pay': + paid_amount_field = ("paid_amount" + if doc.paid_to_account_currency == currency else "base_paid_amount") return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, paid_amount_field) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index f184b95a92d..36276023ecd 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -33,6 +33,8 @@ class GLEntry(Document): name will be changed using autoname options (in a scheduled job) """ self.name = frappe.generate_hash(txt="", length=10) + if self.meta.autoname == "hash": + self.to_rename = 0 def validate(self): self.flags.ignore_submit_comment = True @@ -135,7 +137,7 @@ class GLEntry(Document): def check_pl_account(self): if self.is_opening=='Yes' and \ - frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss": + frappe.db.get_value("Account", self.account, "report_type")=="Profit and Loss" and not self.is_cancelled: frappe.throw(_("{0} {1}: 'Profit and Loss' type account {2} not allowed in Opening Entry") .format(self.voucher_type, self.voucher_no, self.account)) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 3eaf6a28f37..77d54a605e5 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( create_dimension, @@ -10,11 +11,10 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) -from erpnext.tests.utils import ERPNextTestCase test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] -class TestOpeningInvoiceCreationTool(ERPNextTestCase): +class TestOpeningInvoiceCreationTool(FrappeTestCase): @classmethod def setUpClass(self): if not frappe.db.exists("Company", "_Test Opening Invoice Company"): diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 1a833a4008e..f77b060ee29 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -376,8 +376,8 @@ def make_payment_request(**args): if args.order_type == "Shopping Cart" or args.mute_email: pr.flags.mute_email = True - pr.insert(ignore_permissions=True) if args.submit_doc: + pr.insert(ignore_permissions=True) pr.submit() if args.order_type == "Shopping Cart": @@ -394,7 +394,10 @@ def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype if dt in ["Sales Order", "Purchase Order"]: - grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) + if ref_doc.party_account_currency == ref_doc.currency: + grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) + else: + grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) / ref_doc.conversion_rate elif dt in ["Sales Invoice", "Purchase Invoice"]: if ref_doc.party_account_currency == ref_doc.currency: diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index f679ccfe4ff..cc70a96e742 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -128,6 +128,7 @@ class TestPaymentRequest(unittest.TestCase): pr1 = make_payment_request(dt="Sales Order", dn=so.name, recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 + pr1.insert() pr1.submit() # Make a 2nd Payment Request diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 9d585411582..431036abc18 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -54,7 +54,7 @@ class POSInvoice(SalesInvoice): def on_submit(self): # create the loyalty point ledger entry if the customer is enrolled in any loyalty program - if self.loyalty_program: + if not self.is_return and self.loyalty_program: self.make_loyalty_point_entry() elif self.is_return and self.return_against and self.loyalty_program: against_psi_doc = frappe.get_doc("POS Invoice", self.return_against) @@ -88,7 +88,7 @@ class POSInvoice(SalesInvoice): def on_cancel(self): # run on cancel method of selling controller super(SalesInvoice, self).on_cancel() - if self.loyalty_program: + if not self.is_return and self.loyalty_program: self.delete_loyalty_point_entry() elif self.is_return and self.return_against and self.loyalty_program: against_psi_doc = frappe.get_doc("POS Invoice", self.return_against) @@ -456,6 +456,7 @@ class POSInvoice(SalesInvoice): pay_req = self.get_existing_payment_request(pay) if not pay_req: pay_req = self.get_new_payment_request(pay) + pay_req.insert() pay_req.submit() else: pay_req.request_phone_payment() 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 89f7f18b42c..8909da96fcf 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 @@ -83,7 +83,10 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): pos_inv_cn = make_sales_return(pos_inv.name) pos_inv_cn.set("payments", []) pos_inv_cn.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -300 + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -100 + }) + pos_inv_cn.append('payments', { + 'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': -200 }) pos_inv_cn.paid_amount = -300 pos_inv_cn.submit() @@ -98,7 +101,12 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): pos_inv_cn.load_from_db() self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv_cn.consolidated_invoice)) - self.assertTrue(frappe.db.get_value("Sales Invoice", pos_inv_cn.consolidated_invoice, "is_return")) + consolidated_credit_note = frappe.get_doc("Sales Invoice", pos_inv_cn.consolidated_invoice) + self.assertEqual(consolidated_credit_note.is_return, 1) + self.assertEqual(consolidated_credit_note.payments[0].mode_of_payment, 'Cash') + self.assertEqual(consolidated_credit_note.payments[0].amount, -100) + self.assertEqual(consolidated_credit_note.payments[1].mode_of_payment, 'Bank Draft') + self.assertEqual(consolidated_credit_note.payments[1].amount, -200) finally: frappe.set_user("Administrator") diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index f3452e1cf81..1c1d913886f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -33,6 +33,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status +from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( @@ -105,6 +106,7 @@ class PurchaseInvoice(BuyingController): self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "stock_qty") self.set_expense_account(for_validate=True) + self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount", "items") @@ -309,6 +311,10 @@ class PurchaseInvoice(BuyingController): elif not item.expense_account and for_validate: throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) + def validate_expense_account(self): + for item in self.get('items'): + validate_account_head(item.idx, item.expense_account, self.company, 'Expense') + def set_against_expense_account(self): against_accounts = [] for item in self.get("items"): diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 409677f3c26..da746e26d02 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -38,14 +38,18 @@ from erpnext.assets.doctype.asset.depreciation import ( get_gl_entries_on_asset_regain, make_depreciation_entry, ) +from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.selling_controller import SellingController from erpnext.healthcare.utils import manage_invoice_submit_cancel from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so -from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos -from erpnext.stock.utils import calculate_mapped_packed_items_return +from erpnext.stock.doctype.serial_no.serial_no import ( + get_delivery_note_serial_no, + get_serial_nos, + update_serial_nos_after_submit, +) form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -110,6 +114,8 @@ class SalesInvoice(SellingController): self.validate_fixed_asset() self.set_income_account_for_fixed_assets() self.validate_item_cost_centers() + self.validate_income_account() + validate_inter_company_party(self.doctype, self.customer, self.company, self.inter_company_invoice_reference) if cint(self.is_pos): @@ -177,6 +183,10 @@ class SalesInvoice(SellingController): if cost_center_company != self.company: frappe.throw(_("Row #{0}: Cost Center {1} does not belong to company {2}").format(frappe.bold(item.idx), frappe.bold(item.cost_center), frappe.bold(self.company))) + def validate_income_account(self): + for item in self.get('items'): + validate_account_head(item.idx, item.income_account, self.company, 'Income') + def set_tax_withholding(self): tax_withholding_details = get_party_tax_withholding_details(self) @@ -228,6 +238,9 @@ class SalesInvoice(SellingController): # because updating reserved qty in bin depends upon updated delivered qty in SO if self.update_stock == 1: self.update_stock_ledger() + if self.is_return and self.update_stock: + update_serial_nos_after_submit(self, "items") + # this sequence because outstanding may get -ve self.make_gl_entries() @@ -752,11 +765,8 @@ class SalesInvoice(SellingController): def update_packing_list(self): if cint(self.update_stock) == 1: - if cint(self.is_return) and self.return_against: - calculate_mapped_packed_items_return(self) - else: - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) + from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + make_packing_list(self) else: self.set('packed_items', []) @@ -1415,12 +1425,19 @@ class SalesInvoice(SellingController): frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name) def get_returned_amount(self): - returned_amount = frappe.db.sql(""" - select sum(grand_total) - from `tabSales Invoice` - where docstatus=1 and is_return=1 and ifnull(return_against, '')=%s - """, self.name) - return abs(flt(returned_amount[0][0])) if returned_amount else 0 + from frappe.query_builder.functions import Coalesce, Sum + doc = frappe.qb.DocType(self.doctype) + returned_amount = ( + frappe.qb.from_(doc) + .select(Sum(doc.grand_total)) + .where( + (doc.docstatus == 1) + & (doc.is_return == 1) + & (Coalesce(doc.return_against, '') == self.name) + ) + ).run() + + return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 # redeem the loyalty points. def apply_loyalty_points(self): @@ -1700,7 +1717,6 @@ def make_maintenance_schedule(source_name, target_doc=None): @frappe.whitelist() def make_delivery_note(source_name, target_doc=None): def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") @@ -1745,6 +1761,7 @@ def make_delivery_note(source_name, target_doc=None): } }, target_doc, set_missing_values) + doclist.set_onload('ignore_price_list', True) return doclist @frappe.whitelist() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 69ab1738bc6..fcdf8474427 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -812,12 +812,37 @@ class TestSalesInvoice(unittest.TestCase): pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 50}) pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - TCP1', 'amount': 60}) - pos.change_amount = 5.0 + pos.write_off_outstanding_amount_automatically = 1 pos.insert() pos.submit() self.assertEqual(pos.grand_total, 100.0) - self.assertEqual(pos.write_off_amount, -5) + self.assertEqual(pos.write_off_amount, 0) + + def test_auto_write_off_amount(self): + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") + + make_purchase_receipt(company= "_Test Company with perpetual inventory", + item_code= "_Test FG Item",warehouse= "Stores - TCP1", cost_center= "Main - TCP1") + + pos = create_sales_invoice(company= "_Test Company with perpetual inventory", + debit_to="Debtors - TCP1", item_code= "_Test FG Item", warehouse="Stores - TCP1", + income_account = "Sales - TCP1", expense_account = "Cost of Goods Sold - TCP1", + cost_center = "Main - TCP1", do_not_save=True) + + pos.is_pos = 1 + pos.update_stock = 1 + + pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 50}) + pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - TCP1', 'amount': 40}) + + pos.write_off_outstanding_amount_automatically = 1 + pos.insert() + pos.submit() + + self.assertEqual(pos.grand_total, 100.0) + self.assertEqual(pos.write_off_amount, 10) def test_pos_with_no_gl_entry_for_change_amount(self): frappe.db.set_value('Accounts Settings', None, 'post_change_gl_entries', 0) @@ -2540,6 +2565,12 @@ class TestSalesInvoice(unittest.TestCase): frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) + def test_standalone_serial_no_return(self): + si = create_sales_invoice(item_code="_Test Serialized Item With Series", update_stock=True, is_return=True, qty=-1) + si.reload() + self.assertTrue(si.items[0].serial_no) + + def get_sales_invoice_for_e_invoice(): si = make_sales_invoice_for_ewaybill() si.naming_series = 'INV-2020-.#####' diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 792e7d21a78..7e5129911e4 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -71,8 +71,7 @@ class ShippingRule(Document): if doc.currency != doc.company_currency: shipping_amount = flt(shipping_amount / doc.conversion_rate, 2) - if shipping_amount: - self.add_shipping_rule_to_tax_table(doc, shipping_amount) + self.add_shipping_rule_to_tax_table(doc, shipping_amount) def get_shipping_amount_from_rules(self, value): for condition in self.get("conditions"): diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py index b72d2669775..fe644ed3569 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py @@ -267,7 +267,7 @@ def get_loan_amount(filters): total_amount += flt(amount) - return amount + return total_amount def get_balance_row(label, amount, account_currency): if amount > 0: diff --git a/erpnext/accounts/report/cash_flow/custom_cash_flow.py b/erpnext/accounts/report/cash_flow/custom_cash_flow.py index 45d147e7a21..e81e0d73cdb 100644 --- a/erpnext/accounts/report/cash_flow/custom_cash_flow.py +++ b/erpnext/accounts/report/cash_flow/custom_cash_flow.py @@ -4,7 +4,8 @@ import frappe from frappe import _ -from frappe.utils import add_to_date +from frappe.query_builder.functions import Sum +from frappe.utils import add_to_date, flt, get_date_str from erpnext.accounts.report.financial_statements import get_columns, get_data, get_period_list from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import ( @@ -28,15 +29,22 @@ def get_mappers_from_db(): def get_accounts_in_mappers(mapping_names): - return frappe.db.sql(''' - select cfma.name, cfm.label, cfm.is_working_capital, cfm.is_income_tax_liability, - cfm.is_income_tax_expense, cfm.is_finance_cost, cfm.is_finance_cost_adjustment - from `tabCash Flow Mapping Accounts` cfma - join `tabCash Flow Mapping` cfm on cfma.parent=cfm.name - where cfma.parent in (%s) - order by cfm.is_working_capital - ''', (', '.join('"%s"' % d for d in mapping_names))) + cfm = frappe.qb.DocType('Cash Flow Mapping') + cfma = frappe.qb.DocType('Cash Flow Mapping Accounts') + result = ( + frappe.qb + .select( + cfma.name, cfm.label, cfm.is_working_capital, + cfm.is_income_tax_liability, cfm.is_income_tax_expense, + cfm.is_finance_cost, cfm.is_finance_cost_adjustment, cfma.account + ) + .from_(cfm) + .join(cfma) + .on(cfm.name == cfma.parent) + .where(cfma.parent.isin(mapping_names)) + ).run() + return result def setup_mappers(mappers): cash_flow_accounts = [] @@ -57,31 +65,31 @@ def setup_mappers(mappers): account_types = [ dict( - name=account[0], label=account[1], is_working_capital=account[2], + name=account[0], account_name=account[7], label=account[1], is_working_capital=account[2], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if not account[3]] finance_costs_adjustments = [ dict( - name=account[0], label=account[1], is_finance_cost=account[5], + name=account[0], account_name=account[7], label=account[1], is_finance_cost=account[5], is_finance_cost_adjustment=account[6] ) for account in accounts if account[6]] tax_liabilities = [ dict( - name=account[0], label=account[1], is_income_tax_liability=account[3], + name=account[0], account_name=account[7], label=account[1], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if account[3]] tax_expenses = [ dict( - name=account[0], label=account[1], is_income_tax_liability=account[3], + name=account[0], account_name=account[7], label=account[1], is_income_tax_liability=account[3], is_income_tax_expense=account[4] ) for account in accounts if account[4]] finance_costs = [ dict( - name=account[0], label=account[1], is_finance_cost=account[5]) + name=account[0], account_name=account[7], label=account[1], is_finance_cost=account[5]) for account in accounts if account[5]] account_types_labels = sorted( @@ -124,27 +132,27 @@ def setup_mappers(mappers): ) for label in account_types_labels: - names = [d['name'] for d in account_types if d['label'] == label[0]] + names = [d['account_name'] for d in account_types if d['label'] == label[0]] m = dict(label=label[0], names=names, is_working_capital=label[1]) mapping['account_types'].append(m) for label in fc_adjustment_labels: - names = [d['name'] for d in finance_costs_adjustments if d['label'] == label[0]] + names = [d['account_name'] for d in finance_costs_adjustments if d['label'] == label[0]] m = dict(label=label[0], names=names) mapping['finance_costs_adjustments'].append(m) for label in unique_liability_labels: - names = [d['name'] for d in tax_liabilities if d['label'] == label[0]] + names = [d['account_name'] for d in tax_liabilities if d['label'] == label[0]] m = dict(label=label[0], names=names, tax_liability=label[1], tax_expense=label[2]) mapping['tax_liabilities'].append(m) for label in unique_expense_labels: - names = [d['name'] for d in tax_expenses if d['label'] == label[0]] + names = [d['account_name'] for d in tax_expenses if d['label'] == label[0]] m = dict(label=label[0], names=names, tax_liability=label[1], tax_expense=label[2]) mapping['tax_expenses'].append(m) for label in unique_finance_costs_labels: - names = [d['name'] for d in finance_costs if d['label'] == label[0]] + names = [d['account_name'] for d in finance_costs if d['label'] == label[0]] m = dict(label=label[0], names=names, is_finance_cost=label[1]) mapping['finance_costs'].append(m) @@ -371,14 +379,30 @@ def execute(filters=None): def _get_account_type_based_data(filters, account_names, period_list, accumulated_values, opening_balances=0): + if not account_names or not account_names[0] or not type(account_names[0]) == str: + # only proceed if account_names is a list of account names + return {} + from erpnext.accounts.report.cash_flow.cash_flow import get_start_date company = filters.company data = {} total = 0 + GLEntry = frappe.qb.DocType('GL Entry') + Account = frappe.qb.DocType('Account') + for period in period_list: start_date = get_start_date(period, accumulated_values, company) - accounts = ', '.join('"%s"' % d for d in account_names) + + account_subquery = ( + frappe.qb.from_(Account) + .where( + (Account.name.isin(account_names)) | + (Account.parent_account.isin(account_names)) + ) + .select(Account.name) + .as_("account_subquery") + ) if opening_balances: date_info = dict(date=start_date) @@ -395,32 +419,31 @@ def _get_account_type_based_data(filters, account_names, period_list, accumulate else: start, end = add_to_date(**date_info), add_to_date(**date_info) - gl_sum = frappe.db.sql_list(""" - select sum(credit) - sum(debit) - from `tabGL Entry` - where company=%s and posting_date >= %s and posting_date <= %s - and voucher_type != 'Period Closing Voucher' - and account in ( SELECT name FROM tabAccount WHERE name IN (%s) - OR parent_account IN (%s)) - """, (company, start, end, accounts, accounts)) - else: - gl_sum = frappe.db.sql_list(""" - select sum(credit) - sum(debit) - from `tabGL Entry` - where company=%s and posting_date >= %s and posting_date <= %s - and voucher_type != 'Period Closing Voucher' - and account in ( SELECT name FROM tabAccount WHERE name IN (%s) - OR parent_account IN (%s)) - """, (company, start_date if accumulated_values else period['from_date'], - period['to_date'], accounts, accounts)) + start, end = get_date_str(start), get_date_str(end) - if gl_sum and gl_sum[0]: - amount = gl_sum[0] else: - amount = 0 + start, end = start_date if accumulated_values else period['from_date'], period['to_date'] + start, end = get_date_str(start), get_date_str(end) - total += amount - data.setdefault(period["key"], amount) + result = ( + frappe.qb.from_(GLEntry) + .select(Sum(GLEntry.credit) - Sum(GLEntry.debit)) + .where( + (GLEntry.company == company) & + (GLEntry.posting_date >= start) & + (GLEntry.posting_date <= end) & + (GLEntry.voucher_type != 'Period Closing Voucher') & + (GLEntry.account.isin(account_subquery)) + ) + ).run() + + if result and result[0]: + gl_sum = result[0][0] + else: + gl_sum = 0 + + total += flt(gl_sum) + data.setdefault(period["key"], flt(gl_sum)) data["total"] = total return data diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py index 86eb2134fe8..17475a77dbf 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py @@ -88,10 +88,12 @@ class TestDeferredRevenueAndExpense(unittest.TestCase): posting_date="2021-05-01", parent_cost_center="Main - _CD", cost_center="Main - _CD", - do_not_submit=True, + do_not_save=True, rate=300, price_list_rate=300, ) + + si.items[0].income_account = "Sales - _CD" si.items[0].enable_deferred_revenue = 1 si.items[0].service_start_date = "2021-05-01" si.items[0].service_end_date = "2021-08-01" @@ -269,11 +271,13 @@ class TestDeferredRevenueAndExpense(unittest.TestCase): posting_date="2021-05-01", parent_cost_center="Main - _CD", cost_center="Main - _CD", - do_not_submit=True, + do_not_save=True, rate=300, price_list_rate=300, ) + si.items[0].enable_deferred_revenue = 1 + si.items[0].income_account = "Sales - _CD" si.items[0].deferred_revenue_account = deferred_revenue_account si.items[0].income_account = "Sales - _CD" si.save() diff --git a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.js b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.js index 6a0394861b8..ea05a35b259 100644 --- a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.js +++ b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.js @@ -39,12 +39,14 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "label": __("From Date"), "fieldtype": "Date", "default": frappe.defaults.get_user_default("year_start_date"), + "reqd": 1 }, { "fieldname": "to_date", "label": __("To Date"), "fieldtype": "Date", "default": frappe.defaults.get_user_default("year_end_date"), + "reqd": 1 }, { "fieldname": "finance_book", @@ -56,6 +58,7 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "fieldname": "dimension", "label": __("Select Dimension"), "fieldtype": "Select", + "default": "Cost Center", "options": get_accounting_dimension_options(), "reqd": 1, }, @@ -70,7 +73,7 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { }); function get_accounting_dimension_options() { - let options =["", "Cost Center", "Project"]; + let options =["Cost Center", "Project"]; frappe.db.get_list('Accounting Dimension', {fields:['document_type']}).then((res) => { res.forEach((dimension) => { diff --git a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py index c69bb3f70c5..90992ac744c 100644 --- a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py +++ b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py @@ -16,20 +16,21 @@ from erpnext.accounts.report.trial_balance.trial_balance import validate_filters def execute(filters=None): - validate_filters(filters) - dimension_items_list = get_dimension_items_list(filters.dimension, filters.company) - if not dimension_items_list: + validate_filters(filters) + dimension_list = get_dimensions(filters) + + if not dimension_list: return [], [] - dimension_items_list = [''.join(d) for d in dimension_items_list] - columns = get_columns(dimension_items_list) - data = get_data(filters, dimension_items_list) + columns = get_columns(dimension_list) + data = get_data(filters, dimension_list) return columns, data -def get_data(filters, dimension_items_list): +def get_data(filters, dimension_list): company_currency = erpnext.get_company_currency(filters.company) + acc = frappe.db.sql(""" select name, account_number, parent_account, lft, rgt, root_type, @@ -52,60 +53,54 @@ def get_data(filters, dimension_items_list): where lft >= %s and rgt <= %s and company = %s""", (min_lft, max_rgt, filters.company)) gl_entries_by_account = {} - set_gl_entries_by_account(dimension_items_list, filters, account, gl_entries_by_account) - format_gl_entries(gl_entries_by_account, accounts_by_name, dimension_items_list) - accumulate_values_into_parents(accounts, accounts_by_name, dimension_items_list) - out = prepare_data(accounts, filters, parent_children_map, company_currency, dimension_items_list) + set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_account) + format_gl_entries(gl_entries_by_account, accounts_by_name, dimension_list, + frappe.scrub(filters.get('dimension'))) + accumulate_values_into_parents(accounts, accounts_by_name, dimension_list) + out = prepare_data(accounts, filters, company_currency, dimension_list) out = filter_out_zero_value_rows(out, parent_children_map) return out -def set_gl_entries_by_account(dimension_items_list, filters, account, gl_entries_by_account): - for item in dimension_items_list: - condition = get_condition(filters.from_date, item, filters.dimension) - if account: - condition += " and account in ({})"\ - .format(", ".join([frappe.db.escape(d) for d in account])) +def set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_account): + condition = get_condition(filters.get('dimension')) - gl_filters = { - "company": filters.get("company"), - "from_date": filters.get("from_date"), - "to_date": filters.get("to_date"), - "finance_book": cstr(filters.get("finance_book")) - } + if account: + condition += " and account in ({})"\ + .format(", ".join([frappe.db.escape(d) for d in account])) - gl_filters['item'] = ''.join(item) + gl_filters = { + "company": filters.get("company"), + "from_date": filters.get("from_date"), + "to_date": filters.get("to_date"), + "finance_book": cstr(filters.get("finance_book")) + } - if filters.get("include_default_book_entries"): - gl_filters["company_fb"] = frappe.db.get_value("Company", - filters.company, 'default_finance_book') + gl_filters['dimensions'] = set(dimension_list) - for key, value in filters.items(): - if value: - gl_filters.update({ - key: value - }) + if filters.get("include_default_book_entries"): + gl_filters["company_fb"] = frappe.db.get_value("Company", + filters.company, 'default_finance_book') - gl_entries = frappe.db.sql(""" + gl_entries = frappe.db.sql(""" select - posting_date, account, debit, credit, is_opening, fiscal_year, + posting_date, account, {dimension}, debit, credit, is_opening, fiscal_year, debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry` where company=%(company)s {condition} + and posting_date >= %(from_date)s and posting_date <= %(to_date)s and is_cancelled = 0 order by account, posting_date""".format( - condition=condition), - gl_filters, as_dict=True) #nosec + dimension = frappe.scrub(filters.get('dimension')), condition=condition), gl_filters, as_dict=True) #nosec - for entry in gl_entries: - entry['dimension_item'] = ''.join(item) - gl_entries_by_account.setdefault(entry.account, []).append(entry) + for entry in gl_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) -def format_gl_entries(gl_entries_by_account, accounts_by_name, dimension_items_list): +def format_gl_entries(gl_entries_by_account, accounts_by_name, dimension_list, dimension_type): for entries in itervalues(gl_entries_by_account): for entry in entries: @@ -115,11 +110,12 @@ def format_gl_entries(gl_entries_by_account, accounts_by_name, dimension_items_l _("Could not retrieve information for {0}.").format(entry.account), title="Error", raise_exception=1 ) - for item in dimension_items_list: - if item == entry.dimension_item: - d[frappe.scrub(item)] = d.get(frappe.scrub(item), 0.0) + flt(entry.debit) - flt(entry.credit) -def prepare_data(accounts, filters, parent_children_map, company_currency, dimension_items_list): + for dimension in dimension_list: + if dimension == entry.get(dimension_type): + d[frappe.scrub(dimension)] = d.get(frappe.scrub(dimension), 0.0) + flt(entry.debit) - flt(entry.credit) + +def prepare_data(accounts, filters, company_currency, dimension_list): data = [] for d in accounts: @@ -136,13 +132,13 @@ def prepare_data(accounts, filters, parent_children_map, company_currency, dimen if d.account_number else d.account_name) } - for item in dimension_items_list: - row[frappe.scrub(item)] = flt(d.get(frappe.scrub(item), 0.0), 3) + for dimension in dimension_list: + row[frappe.scrub(dimension)] = flt(d.get(frappe.scrub(dimension), 0.0), 3) - if abs(row[frappe.scrub(item)]) >= 0.005: + if abs(row[frappe.scrub(dimension)]) >= 0.005: # ignore zero values has_value = True - total += flt(d.get(frappe.scrub(item), 0.0), 3) + total += flt(d.get(frappe.scrub(dimension), 0.0), 3) row["has_value"] = has_value row["total"] = total @@ -150,62 +146,55 @@ def prepare_data(accounts, filters, parent_children_map, company_currency, dimen return data -def accumulate_values_into_parents(accounts, accounts_by_name, dimension_items_list): +def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list): """accumulate children's values in parent accounts""" for d in reversed(accounts): if d.parent_account: - for item in dimension_items_list: - accounts_by_name[d.parent_account][frappe.scrub(item)] = \ - accounts_by_name[d.parent_account].get(frappe.scrub(item), 0.0) + d.get(frappe.scrub(item), 0.0) + for dimension in dimension_list: + accounts_by_name[d.parent_account][frappe.scrub(dimension)] = \ + accounts_by_name[d.parent_account].get(frappe.scrub(dimension), 0.0) + d.get(frappe.scrub(dimension), 0.0) -def get_condition(from_date, item, dimension): +def get_condition(dimension): conditions = [] - if from_date: - conditions.append("posting_date >= %(from_date)s") - if dimension: - if dimension not in ['Cost Center', 'Project']: - if dimension in ['Customer', 'Supplier']: - dimension = 'Party' - else: - dimension = 'Voucher No' - txt = "{0} = %(item)s".format(frappe.scrub(dimension)) - conditions.append(txt) + conditions.append("{0} in %(dimensions)s".format(frappe.scrub(dimension))) return " and {}".format(" and ".join(conditions)) if conditions else "" -def get_dimension_items_list(dimension, company): - meta = frappe.get_meta(dimension, cached=False) - fieldnames = [d.fieldname for d in meta.get("fields")] - filters = {} - if 'company' in fieldnames: - filters['company'] = company - return frappe.get_all(dimension, filters, as_list=True) +def get_dimensions(filters): + meta = frappe.get_meta(filters.get('dimension'), cached=False) + query_filters = {} -def get_columns(dimension_items_list, accumulated_values=1, company=None): + if meta.has_field('company'): + query_filters = {'company': filters.get('company')} + + return frappe.get_all(filters.get('dimension'), filters=query_filters, pluck='name') + +def get_columns(dimension_list): columns = [{ "fieldname": "account", "label": _("Account"), "fieldtype": "Link", "options": "Account", "width": 300 + }, + { + "fieldname": "currency", + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "hidden": 1 }] - if company: + + for dimension in dimension_list: columns.append({ - "fieldname": "currency", - "label": _("Currency"), - "fieldtype": "Link", - "options": "Currency", - "hidden": 1 - }) - for item in dimension_items_list: - columns.append({ - "fieldname": frappe.scrub(item), - "label": item, + "fieldname": frappe.scrub(dimension), + "label": dimension, "fieldtype": "Currency", "options": "currency", "width": 150 }) + columns.append({ "fieldname": "total", "label": "Total", diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py new file mode 100644 index 00000000000..2373c8c2a67 --- /dev/null +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -0,0 +1,134 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.report.general_ledger.general_ledger import execute + + +class TestGeneralLedger(FrappeTestCase): + + def test_foreign_account_balance_after_exchange_rate_revaluation(self): + """ + Checks the correctness of balance after exchange rate revaluation + """ + # create a new account with USD currency + account_name = "Test USD Account for Revalutation" + company = "_Test Company" + account = frappe.get_doc({ + "account_name": account_name, + "is_group": 0, + "company": company, + "root_type": "Asset", + "report_type": "Balance Sheet", + "account_currency": "USD", + "inter_company_account": 0, + "parent_account": "Bank Accounts - _TC", + "account_type": "Bank", + "doctype": "Account" + }) + account.insert(ignore_if_duplicate=True) + # create a JV to debit 1000 USD at 75 exchange rate + jv = frappe.new_doc("Journal Entry") + jv.posting_date = today() + jv.company = company + jv.multi_currency = 1 + jv.cost_center = "_Test Cost Center - _TC" + jv.set("accounts", [ + { + "account": account.name, + "debit_in_account_currency": 1000, + "credit_in_account_currency": 0, + "exchange_rate": 75, + "cost_center": "_Test Cost Center - _TC", + }, + { + "account": "Cash - _TC", + "debit_in_account_currency": 0, + "credit_in_account_currency": 75000, + "cost_center": "_Test Cost Center - _TC", + }, + ]) + jv.save() + jv.submit() + # create a JV to credit 900 USD at 100 exchange rate + jv = frappe.new_doc("Journal Entry") + jv.posting_date = today() + jv.company = company + jv.multi_currency = 1 + jv.cost_center = "_Test Cost Center - _TC" + jv.set("accounts", [ + { + "account": account.name, + "debit_in_account_currency": 0, + "credit_in_account_currency": 900, + "exchange_rate": 100, + "cost_center": "_Test Cost Center - _TC", + }, + { + "account": "Cash - _TC", + "debit_in_account_currency": 90000, + "credit_in_account_currency": 0, + "cost_center": "_Test Cost Center - _TC", + }, + ]) + jv.save() + jv.submit() + + # create an exchange rate revaluation entry at 77 exchange rate + revaluation = frappe.new_doc("Exchange Rate Revaluation") + revaluation.posting_date = today() + revaluation.company = company + revaluation.set("accounts", [ + { + "account": account.name, + "account_currency": "USD", + "new_exchange_rate": 77, + "new_balance_in_base_currency": 7700, + "balance_in_base_currency": -15000, + "balance_in_account_currency": 100, + "current_exchange_rate": -150 + } + ]) + revaluation.save() + revaluation.submit() + + # post journal entry to revaluate + frappe.db.set_value('Company', company, "unrealized_exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC") + revaluation_jv = revaluation.make_jv_entry() + revaluation_jv = frappe.get_doc(revaluation_jv) + revaluation_jv.cost_center = "_Test Cost Center - _TC" + for acc in revaluation_jv.get("accounts"): + acc.cost_center = "_Test Cost Center - _TC" + revaluation_jv.save() + revaluation_jv.submit() + + # check the balance of the account + balance = frappe.db.sql( + """ + select sum(debit_in_account_currency) - sum(credit_in_account_currency) + from `tabGL Entry` + where account = %s + group by account + """, account.name) + + self.assertEqual(balance[0][0], 100) + + # check if general ledger shows correct balance + columns, data = execute(frappe._dict({ + "company": company, + "from_date": today(), + "to_date": today(), + "account": [account.name], + "group_by": "Group by Voucher (Consolidated)", + })) + + self.assertEqual(data[1]["account"], account.name) + self.assertEqual(data[1]["debit"], 1000) + self.assertEqual(data[1]["credit"], 0) + self.assertEqual(data[2]["debit"], 0) + self.assertEqual(data[2]["credit"], 900) + self.assertEqual(data[3]["debit"], 100) + self.assertEqual(data[3]["credit"], 100) \ No newline at end of file diff --git a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py index d843dfd3ce3..70effce5684 100644 --- a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py +++ b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py @@ -107,6 +107,7 @@ def get_opening_balances(filters): select party, sum(debit) as opening_debit, sum(credit) as opening_credit from `tabGL Entry` where company=%(company)s + and is_cancelled=0 and ifnull(party_type, '') = %(party_type)s and ifnull(party, '') != '' and (posting_date < %(from_date)s or ifnull(is_opening, 'No') = 'Yes') {account_filter} @@ -133,6 +134,7 @@ def get_balances_within_period(filters): select party, sum(debit) as debit, sum(credit) as credit from `tabGL Entry` where company=%(company)s + and is_cancelled = 0 and ifnull(party_type, '') = %(party_type)s and ifnull(party, '') != '' and posting_date >= %(from_date)s and posting_date <= %(to_date)s and ifnull(is_opening, 'No') = 'No' diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 89cc0a8c8cc..fa8d69d7df8 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -93,10 +93,10 @@ def convert_to_presentation_currency(gl_entries, currency_info, company): account_currency = entry['account_currency'] if len(account_currencies) == 1 and account_currency == presentation_currency: - if entry.get('debit'): + if debit_in_account_currency: entry['debit'] = debit_in_account_currency - if entry.get('credit'): + if credit_in_account_currency: entry['credit'] = credit_in_account_currency else: date = currency_info['report_date'] diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 2e7d3063ccb..f93f9feb88d 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -402,7 +402,6 @@ def close_or_unclose_purchase_orders(names, status): frappe.local.message_log = [] def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.run_method("set_missing_values") target.run_method("calculate_taxes_and_totals") @@ -443,6 +442,8 @@ def make_purchase_receipt(source_name, target_doc=None): } }, target_doc, set_missing_values) + doc.set_onload('ignore_price_list', True) + return doc @frappe.whitelist() @@ -510,6 +511,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions doc = get_mapped_doc("Purchase Order", source_name, fields, target_doc, postprocess, ignore_permissions=ignore_permissions) + doc.set_onload('ignore_price_list', True) return doc diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 023c95d697d..567e41fb61f 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -72,8 +72,8 @@ "section_break_46", "base_grand_total", "base_rounding_adjustment", - "base_in_words", "base_rounded_total", + "base_in_words", "column_break4", "grand_total", "rounding_adjustment", @@ -635,6 +635,7 @@ "fieldname": "rounded_total", "fieldtype": "Currency", "label": "Rounded Total", + "options": "currency", "read_only": 1 }, { @@ -810,7 +811,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-12-11 06:43:20.924080", + "modified": "2022-03-14 16:13:20.284572", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", @@ -875,6 +876,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "supplier", "title_field": "title" } \ No newline at end of file diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index d65ab94a6d3..9b9c5d51680 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -109,7 +109,6 @@ def get_list_context(context=None): @frappe.whitelist() def make_purchase_order(source_name, target_doc=None): def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.run_method("set_missing_values") target.run_method("get_schedule_dates") target.run_method("calculate_taxes_and_totals") @@ -140,6 +139,7 @@ def make_purchase_order(source_name, target_doc=None): }, }, target_doc, set_missing_values) + doclist.set_onload('ignore_price_list', True) return doclist @frappe.whitelist() diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f146071c0bd..23449082b89 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1567,12 +1567,12 @@ def validate_taxes_and_charges(tax): tax.rate = None -def validate_account_head(idx, account, company): +def validate_account_head(idx, account, company, context=''): account_company = frappe.get_cached_value('Account', account, 'company') if account_company != company: - frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}') - .format(idx, frappe.bold(account), frappe.bold(company)), title=_('Invalid Account')) + frappe.throw(_('Row {0}: {3} Account {1} does not belong to Company {2}') + .format(idx, frappe.bold(account), frappe.bold(company), context), title=_('Invalid Account')) def validate_cost_center(tax, doc): diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index f5c566023c6..ea1c90c250d 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -168,7 +168,7 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters): {account_type_condition} AND is_group = 0 AND company = %(company)s - AND account_currency = %(currency)s + AND (account_currency = %(currency)s or ifnull(account_currency, '') = '') AND `{searchfield}` LIKE %(txt)s {mcond} ORDER BY idx DESC, name diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index df3c5f10c1b..5b72d14dda5 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -208,10 +208,15 @@ def get_already_returned_items(doc): return items -def get_returned_qty_map_for_row(row_name, doctype): +def get_returned_qty_map_for_row(return_against, party, row_name, doctype): child_doctype = doctype + " Item" reference_field = "dn_detail" if doctype == "Delivery Note" else frappe.scrub(child_doctype) + if doctype in ('Purchase Receipt', 'Purchase Invoice'): + party_type = 'supplier' + else: + party_type = 'customer' + fields = [ "sum(abs(`tab{0}`.qty)) as qty".format(child_doctype), "sum(abs(`tab{0}`.stock_qty)) as stock_qty".format(child_doctype) @@ -226,9 +231,12 @@ def get_returned_qty_map_for_row(row_name, doctype): if doctype == "Purchase Receipt": fields += ["sum(abs(`tab{0}`.received_stock_qty)) as received_stock_qty".format(child_doctype)] + # Used retrun against and supplier and is_retrun because there is an index added for it data = frappe.db.get_list(doctype, fields = fields, filters = [ + [doctype, "return_against", "=", return_against], + [doctype, party_type, "=", party], [doctype, "docstatus", "=", 1], [doctype, "is_return", "=", 1], [child_doctype, reference_field, "=", row_name] @@ -307,7 +315,7 @@ def make_return_doc(doctype, source_name, target_doc=None): target_doc.serial_no = '\n'.join(serial_nos) if doctype == "Purchase Receipt": - returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + returned_qty_map = get_returned_qty_map_for_row(source_parent.name, source_parent.supplier, source_doc.name, doctype) target_doc.received_qty = -1 * flt(source_doc.received_qty - (returned_qty_map.get('received_qty') or 0)) target_doc.rejected_qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get('rejected_qty') or 0)) target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) @@ -321,7 +329,7 @@ def make_return_doc(doctype, source_name, target_doc=None): target_doc.purchase_receipt_item = source_doc.name elif doctype == "Purchase Invoice": - returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + returned_qty_map = get_returned_qty_map_for_row(source_parent.name, source_parent.supplier, source_doc.name, doctype) target_doc.received_qty = -1 * flt(source_doc.received_qty - (returned_qty_map.get('received_qty') or 0)) target_doc.rejected_qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get('rejected_qty') or 0)) target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) @@ -335,7 +343,7 @@ def make_return_doc(doctype, source_name, target_doc=None): target_doc.purchase_invoice_item = source_doc.name elif doctype == "Delivery Note": - returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + returned_qty_map = get_returned_qty_map_for_row(source_parent.name, source_parent.customer, source_doc.name, doctype) target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) @@ -348,7 +356,7 @@ def make_return_doc(doctype, source_name, target_doc=None): if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return elif doctype == "Sales Invoice" or doctype == "POS Invoice": - returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + returned_qty_map = get_returned_qty_map_for_row(source_parent.name, source_parent.customer, source_doc.name, doctype) target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) @@ -391,6 +399,8 @@ def make_return_doc(doctype, source_name, target_doc=None): } }, target_doc, set_missing_values) + doclist.set_onload('ignore_price_list', True) + return doclist def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None, diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index e218c0cd031..f926a893147 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -37,6 +37,8 @@ class calculate_taxes_and_totals(object): self.set_discount_amount() self.apply_discount_amount() + self.calculate_shipping_charges() + if self.doc.doctype in ["Sales Invoice", "Purchase Invoice"]: self.calculate_total_advance() @@ -50,7 +52,6 @@ class calculate_taxes_and_totals(object): self.initialize_taxes() self.determine_exclusive_rate() self.calculate_net_total() - self.calculate_shipping_charges() self.calculate_taxes() self.manipulate_grand_total_for_inclusive_tax() self.calculate_totals() @@ -113,20 +114,16 @@ class calculate_taxes_and_totals(object): for item in self.doc.get("items"): self.doc.round_floats_in(item) - if not item.rate: - item.rate = item.price_list_rate - if item.discount_percentage == 100: item.rate = 0.0 elif item.price_list_rate: - if item.pricing_rules or abs(item.discount_percentage) > 0: + if not item.rate or (item.pricing_rules and item.discount_percentage > 0): item.rate = flt(item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) - if abs(item.discount_percentage) > 0: - item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) + item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) - elif item.discount_amount or item.pricing_rules: + elif item.discount_amount and item.pricing_rules: item.rate = item.price_list_rate - item.discount_amount if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', @@ -276,6 +273,8 @@ class calculate_taxes_and_totals(object): shipping_rule = frappe.get_doc("Shipping Rule", self.doc.shipping_rule) shipping_rule.apply(self.doc) + self._calculate() + def calculate_taxes(self): rounding_adjustment_computed = self.doc.get('is_consolidated') and self.doc.get('rounding_adjustment') if not rounding_adjustment_computed: @@ -581,7 +580,11 @@ class calculate_taxes_and_totals(object): .format(self.doc.party_account_currency, invoice_total)) if self.doc.docstatus == 0: + if self.doc.get('write_off_outstanding_amount_automatically'): + self.doc.write_off_amount = 0 + self.calculate_outstanding_amount() + self.calculate_write_off_amount() def is_internal_invoice(self): """ @@ -622,7 +625,6 @@ class calculate_taxes_and_totals(object): change_amount = 0 if self.doc.doctype == "Sales Invoice" and not self.doc.get('is_return'): - self.calculate_write_off_amount() self.calculate_change_amount() change_amount = self.doc.change_amount \ if self.doc.party_account_currency == self.doc.currency else self.doc.base_change_amount @@ -633,8 +635,14 @@ class calculate_taxes_and_totals(object): self.doc.outstanding_amount = flt(total_amount_to_pay - flt(paid_amount) + flt(change_amount), self.doc.precision("outstanding_amount")) - if self.doc.doctype == 'Sales Invoice' and self.doc.get('is_pos') and self.doc.get('is_return'): - self.update_paid_amount_for_return(total_amount_to_pay) + if ( + self.doc.doctype == 'Sales Invoice' + and self.doc.get('is_pos') + and self.doc.get('is_return') + and not self.doc.get('is_consolidated') + ): + self.set_total_amount_to_default_mop(total_amount_to_pay) + self.calculate_paid_amount() def calculate_paid_amount(self): @@ -666,19 +674,20 @@ class calculate_taxes_and_totals(object): 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 + - self.doc.write_off_amount, self.doc.precision("change_amount")) + self.doc.change_amount = flt(self.doc.paid_amount - grand_total, + self.doc.precision("change_amount")) - self.doc.base_change_amount = flt(self.doc.base_paid_amount - base_grand_total + - self.doc.base_write_off_amount, self.doc.precision("base_change_amount")) + self.doc.base_change_amount = flt(self.doc.base_paid_amount - base_grand_total, + self.doc.precision("base_change_amount")) def calculate_write_off_amount(self): - if flt(self.doc.change_amount) > 0: - self.doc.write_off_amount = flt(self.doc.grand_total - self.doc.paid_amount - + self.doc.change_amount, self.doc.precision("write_off_amount")) + if self.doc.get('write_off_outstanding_amount_automatically'): + self.doc.write_off_amount = flt(self.doc.outstanding_amount, self.doc.precision("write_off_amount")) self.doc.base_write_off_amount = flt(self.doc.write_off_amount * self.doc.conversion_rate, self.doc.precision("base_write_off_amount")) + self.calculate_outstanding_amount() + def calculate_margin(self, item): rate_with_margin = 0.0 base_rate_with_margin = 0.0 @@ -714,7 +723,7 @@ class calculate_taxes_and_totals(object): def set_item_wise_tax_breakup(self): self.doc.other_charges_calculation = get_itemised_tax_breakup_html(self.doc) - def update_paid_amount_for_return(self, total_amount_to_pay): + def set_total_amount_to_default_mop(self, total_amount_to_pay): default_mode_of_payment = frappe.db.get_value('POS Payment Method', {'parent': self.doc.pos_profile, 'default': 1}, ['mode_of_payment'], as_dict=1) @@ -726,8 +735,6 @@ class calculate_taxes_and_totals(object): 'default': 1 }) - self.calculate_paid_amount() - def get_itemised_tax_breakup_html(doc): if not doc.taxes: return diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 809a9d29138..bff4eab68fc 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -189,6 +189,7 @@ class Lead(SellingController): }) contact.insert(ignore_permissions=True) + contact.reload() # load changes by hooks on contact return contact diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js index 741e78f4a55..7108cabfb3f 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.js +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -5,6 +5,12 @@ frappe.ui.form.on('Website Item', { onload: function(frm) { // should never check Private frm.fields_dict["website_image"].df.is_private = 0; + + frm.set_query("website_warehouse", () => { + return { + filters: {"is_group": 0} + }; + }); }, image: function() { diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js index 99b91afac17..eab1699538e 100644 --- a/erpnext/e_commerce/product_ui/views.js +++ b/erpnext/e_commerce/product_ui/views.js @@ -424,6 +424,22 @@ erpnext.ProductView = class { me.change_route_with_filters(); }); + + // bind filter lookup input box + $('.filter-lookup-input').on('keydown', frappe.utils.debounce((e) => { + const $input = $(e.target); + const keyword = ($input.val() || '').toLowerCase(); + const $filter_options = $input.next('.filter-options'); + + $filter_options.find('.filter-lookup-wrapper').show(); + $filter_options.find('.filter-lookup-wrapper').each((i, el) => { + const $el = $(el); + const value = $el.data('value').toLowerCase(); + if (!value.includes(keyword)) { + $el.hide(); + } + }); + }, 300)); } change_route_with_filters() { @@ -501,7 +517,7 @@ erpnext.ProductView = class { categories.forEach(category => { sub_group_html += ` - +
${ category.name }
diff --git a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py index b15bac68234..51d37059570 100644 --- a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -17,7 +17,7 @@ from erpnext.e_commerce.shopping_cart.cart import ( request_for_quotation, update_cart, ) -from erpnext.tests.utils import change_settings, create_test_contact_and_address +from erpnext.tests.utils import create_test_contact_and_address class TestShoppingCart(unittest.TestCase): diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py index 967be838e67..45234b8dbe2 100644 --- a/erpnext/e_commerce/variant_selector/test_variant_selector.py +++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py @@ -1,6 +1,7 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.controllers.item_variant import create_variant from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( @@ -9,11 +10,10 @@ from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings imp from erpnext.e_commerce.doctype.website_item.website_item import make_website_item from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values from erpnext.stock.doctype.item.test_item import make_item -from erpnext.tests.utils import ERPNextTestCase test_dependencies = ["Item"] -class TestVariantSelector(ERPNextTestCase): +class TestVariantSelector(FrappeTestCase): @classmethod def setUpClass(cls): @@ -118,4 +118,4 @@ class TestVariantSelector(ERPNextTestCase): self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") self.assertEqual(price_info["price_list_rate"], 100.0) - self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") \ No newline at end of file + self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json index 03ecf4fb018..e826ecf80bb 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json @@ -24,13 +24,12 @@ "expected_discharge", "references", "admission_encounter", - "admission_practitioner", + "primary_practitioner", "medical_department", "admission_ordered_for", "expected_length_of_stay", "admission_service_unit_type", "cb_admission", - "primary_practitioner", "secondary_practitioner", "admission_instruction", "encounter_details_section", @@ -134,11 +133,11 @@ "read_only": 1 }, { + "fetch_from": "primary_practitioner.department", "fieldname": "medical_department", "fieldtype": "Link", "label": "Medical Department", - "options": "Medical Department", - "set_only_once": 1 + "options": "Medical Department" }, { "fieldname": "primary_practitioner", @@ -211,13 +210,6 @@ "fieldname": "cb_admission", "fieldtype": "Column Break" }, - { - "fieldname": "admission_practitioner", - "fieldtype": "Link", - "label": "Healthcare Practitioner", - "options": "Healthcare Practitioner", - "read_only": 1 - }, { "fieldname": "admission_encounter", "fieldtype": "Link", @@ -412,7 +404,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-08-09 22:49:07.419692", + "modified": "2022-02-22 12:15:02.843426", "modified_by": "Administrator", "module": "Healthcare", "name": "Inpatient Record", diff --git a/erpnext/healthcare/doctype/vital_signs/vital_signs.json b/erpnext/healthcare/doctype/vital_signs/vital_signs.json index a945032c7e0..6d6c351ec35 100644 --- a/erpnext/healthcare/doctype/vital_signs/vital_signs.json +++ b/erpnext/healthcare/doctype/vital_signs/vital_signs.json @@ -72,7 +72,6 @@ "fieldtype": "Link", "in_filter": 1, "label": "Patient Appointment", - "no_copy": 1, "options": "Patient Appointment", "print_hide": 1, "read_only": 1 @@ -82,7 +81,6 @@ "fieldtype": "Link", "in_filter": 1, "label": "Patient Encounter", - "no_copy": 1, "options": "Patient Encounter", "print_hide": 1, "read_only": 1 @@ -258,7 +256,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2022-01-20 12:30:07.515185", + "modified": "2022-02-19 11:48:16.347334", "modified_by": "Administrator", "module": "Healthcare", "name": "Vital Signs", diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index 6095413771c..585059ff479 100644 --- a/erpnext/hr/doctype/attendance/test_attendance.py +++ b/erpnext/hr/doctype/attendance/test_attendance.py @@ -3,7 +3,7 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, get_first_day, getdate, now_datetime, nowdate +from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate from erpnext.hr.doctype.attendance.attendance import ( get_month_map, @@ -16,6 +16,13 @@ from erpnext.hr.doctype.leave_application.test_leave_application import get_firs test_records = frappe.get_test_records('Attendance') class TestAttendance(FrappeTestCase): + def setUp(self): + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list + + from_date = get_year_start(getdate()) + to_date = get_year_ending(getdate()) + self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) + def test_mark_absent(self): employee = make_employee("test_mark_absent@example.com") date = nowdate() @@ -31,12 +38,9 @@ class TestAttendance(FrappeTestCase): employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1)) frappe.db.delete('Attendance', {'employee': employee}) + frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list) - from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list - holiday_list = make_holiday_list() - frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list) - - first_sunday = get_first_sunday(holiday_list, for_date=first_day) + first_sunday = get_first_sunday(self.holiday_list, for_date=first_day) mark_attendance(employee, first_day, 'Present') month_name = get_month_name(first_day) @@ -58,11 +62,9 @@ class TestAttendance(FrappeTestCase): employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1)) frappe.db.delete('Attendance', {'employee': employee}) - from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list - holiday_list = make_holiday_list() - frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list) + frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list) - first_sunday = get_first_sunday(holiday_list, for_date=first_day) + first_sunday = get_first_sunday(self.holiday_list, for_date=first_day) mark_attendance(employee, first_day, 'Present') month_name = get_month_name(first_day) @@ -87,9 +89,7 @@ class TestAttendance(FrappeTestCase): relieving_date=relieving_date) frappe.db.delete('Attendance', {'employee': employee}) - from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list - holiday_list = make_holiday_list() - frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list) + frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list) attendance_date = add_days(first_day, 2) mark_attendance(employee, attendance_date, 'Present') diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py index 5f2e720eb46..2744de96ec3 100644 --- a/erpnext/hr/doctype/employee_advance/test_employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/test_employee_advance.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import nowdate +from frappe.utils import flt, nowdate import erpnext from erpnext.hr.doctype.employee.test_employee import make_employee @@ -13,6 +13,7 @@ from erpnext.hr.doctype.employee_advance.employee_advance import ( create_return_through_additional_salary, make_bank_entry, ) +from erpnext.hr.doctype.expense_claim.expense_claim import get_advances from erpnext.payroll.doctype.salary_component.test_salary_component import create_salary_component from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure @@ -118,3 +119,24 @@ def make_employee_advance(employee_name, args=None): doc.submit() return doc + + +def get_advances_for_claim(claim, advance_name, amount=None): + advances = get_advances(claim.employee, advance_name) + + for entry in advances: + if amount: + allocated_amount = amount + else: + allocated_amount = flt(entry.paid_amount) - flt(entry.claimed_amount) + + claim.append("advances", { + "employee_advance": entry.name, + "posting_date": entry.posting_date, + "advance_account": entry.advance_account, + "advance_paid": entry.paid_amount, + "unclaimed_amount": allocated_amount, + "allocated_amount": allocated_amount + }) + + return claim \ No newline at end of file diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py index 7e3898b7d51..c1bf9c2d984 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/expense_claim.py @@ -23,10 +23,10 @@ class ExpenseClaim(AccountsController): def validate(self): validate_active_employee(self.employee) - self.validate_advances() + set_employee_name(self) self.validate_sanctioned_amount() self.calculate_total_amount() - set_employee_name(self) + self.validate_advances() self.set_expense_account(validate=True) self.set_payable_account() self.set_cost_center() @@ -42,10 +42,18 @@ class ExpenseClaim(AccountsController): "2": "Cancelled" }[cstr(self.docstatus or 0)] - paid_amount = flt(self.total_amount_reimbursed) + flt(self.total_advance_amount) precision = self.precision("grand_total") - if (self.is_paid or (flt(self.total_sanctioned_amount) > 0 and self.docstatus == 1 - and flt(self.grand_total, precision) == flt(paid_amount, precision))) and self.approval_status == 'Approved': + + if ( + # set as paid + self.is_paid + or (flt(self.total_sanctioned_amount > 0) and ( + # grand total is reimbursed + (self.docstatus == 1 and flt(self.grand_total, precision) == flt(self.total_amount_reimbursed, precision)) + # grand total (to be paid) is 0 since linked advances already cover the claimed amount + or (flt(self.grand_total, precision) == 0) + )) + ) and self.approval_status == "Approved": status = "Paid" elif flt(self.total_sanctioned_amount) > 0 and self.docstatus == 1 and self.approval_status == 'Approved': status = "Unpaid" diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index 46958b1ec4c..01b74fb24b4 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -72,6 +72,72 @@ class TestExpenseClaim(unittest.TestCase): expense_claim = frappe.get_doc("Expense Claim", expense_claim.name) self.assertEqual(expense_claim.status, "Unpaid") + # expense claim without any sanctioned amount should not have status as Paid + claim = make_expense_claim(payable_account, 1000, 0, "_Test Company", "Travel Expenses - _TC") + self.assertEqual(claim.total_sanctioned_amount, 0) + self.assertEqual(claim.status, "Submitted") + + # no gl entries created + gl_entry = frappe.get_all('GL Entry', {'voucher_type': 'Expense Claim', 'voucher_no': claim.name}) + self.assertEqual(len(gl_entry), 0) + + def test_expense_claim_against_fully_paid_advances(self): + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + get_advances_for_claim, + make_employee_advance, + make_payment_entry, + ) + + frappe.db.delete("Employee Advance") + + payable_account = get_payable_account("_Test Company") + claim = make_expense_claim(payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + + advance = make_employee_advance(claim.employee) + pe = make_payment_entry(advance) + pe.submit() + + # claim for already paid out advances + claim = get_advances_for_claim(claim, advance.name) + claim.save() + claim.submit() + + self.assertEqual(claim.grand_total, 0) + self.assertEqual(claim.status, "Paid") + + def test_expense_claim_partially_paid_via_advance(self): + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + get_advances_for_claim, + make_employee_advance, + ) + from erpnext.hr.doctype.employee_advance.test_employee_advance import ( + make_payment_entry as make_advance_payment, + ) + + frappe.db.delete("Employee Advance") + + payable_account = get_payable_account("_Test Company") + claim = make_expense_claim(payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + + # link advance for partial amount + advance = make_employee_advance(claim.employee, {'advance_amount': 500}) + pe = make_advance_payment(advance) + pe.submit() + + claim = get_advances_for_claim(claim, advance.name) + claim.save() + claim.submit() + + self.assertEqual(claim.grand_total, 500) + self.assertEqual(claim.status, "Unpaid") + + # reimburse remaning amount + make_payment_entry(claim, payable_account, 500) + claim.reload() + + self.assertEqual(claim.total_amount_reimbursed, 500) + self.assertEqual(claim.status, "Paid") + def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() diff --git a/erpnext/hr/doctype/holiday_list/test_holiday_list.py b/erpnext/hr/doctype/holiday_list/test_holiday_list.py index c9239edb720..aed901aa6da 100644 --- a/erpnext/hr/doctype/holiday_list/test_holiday_list.py +++ b/erpnext/hr/doctype/holiday_list/test_holiday_list.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import unittest +from contextlib import contextmanager from datetime import timedelta import frappe @@ -30,3 +31,24 @@ def make_holiday_list(name, from_date=getdate()-timedelta(days=10), to_date=getd "holidays" : holiday_dates }).insert() return doc + + +@contextmanager +def set_holiday_list(holiday_list, company_name): + """ + Context manager for setting holiday list in tests + """ + try: + company = frappe.get_doc('Company', company_name) + previous_holiday_list = company.default_holiday_list + + company.default_holiday_list = holiday_list + company.save() + + yield + + finally: + # restore holiday list setup + company = frappe.get_doc('Company', company_name) + company.default_holiday_list = previous_holiday_list + company.save() diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js index 9e8cb5516f3..85997a4087f 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.js +++ b/erpnext/hr/doctype/leave_application/leave_application.js @@ -52,7 +52,7 @@ frappe.ui.form.on("Leave Application", { make_dashboard: function(frm) { var leave_details; let lwps; - if (frm.doc.employee) { + if (frm.doc.employee && frm.doc.from_date) { frappe.call({ method: "erpnext.hr.doctype.leave_application.leave_application.get_leave_details", async: false, @@ -146,6 +146,7 @@ frappe.ui.form.on("Leave Application", { }, to_date: function(frm) { + frm.trigger("make_dashboard"); frm.trigger("half_day_datepicker"); frm.trigger("calculate_total_days"); }, diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index ef5f4bcb0ff..518d79aa34b 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from typing import Dict, Optional, Tuple import frappe from frappe import _ @@ -35,6 +36,10 @@ class LeaveDayBlockedError(frappe.ValidationError): pass class OverlapError(frappe.ValidationError): pass class AttendanceAlreadyMarkedError(frappe.ValidationError): pass class NotAnOptionalHoliday(frappe.ValidationError): pass +class InsufficientLeaveBalanceError(frappe.ValidationError): + pass +class LeaveAcrossAllocationsError(frappe.ValidationError): + pass from frappe.model.document import Document @@ -135,21 +140,35 @@ class LeaveApplication(Document): def validate_dates_across_allocation(self): if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"): return - def _get_leave_allocation_record(date): - allocation = frappe.db.sql("""select name from `tabLeave Allocation` - where employee=%s and leave_type=%s and docstatus=1 - and %s between from_date and to_date""", (self.employee, self.leave_type, date)) - return allocation and allocation[0][0] + alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates() + + if not (alloc_on_from_date or alloc_on_to_date): + frappe.throw(_("Application period cannot be outside leave allocation period")) + elif self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date): + frappe.throw(_("Application period cannot be across two allocation records"), exc=LeaveAcrossAllocationsError) + + def get_allocation_based_on_application_dates(self) -> Tuple[Dict, Dict]: + """Returns allocation name, from and to dates for application dates""" + def _get_leave_allocation_record(date): + LeaveAllocation = frappe.qb.DocType("Leave Allocation") + allocation = ( + frappe.qb.from_(LeaveAllocation) + .select(LeaveAllocation.name, LeaveAllocation.from_date, LeaveAllocation.to_date) + .where( + (LeaveAllocation.employee == self.employee) + & (LeaveAllocation.leave_type == self.leave_type) + & (LeaveAllocation.docstatus == 1) + & ((date >= LeaveAllocation.from_date) & (date <= LeaveAllocation.to_date)) + ) + ).run(as_dict=True) + + return allocation and allocation[0] allocation_based_on_from_date = _get_leave_allocation_record(self.from_date) allocation_based_on_to_date = _get_leave_allocation_record(self.to_date) - if not (allocation_based_on_from_date or allocation_based_on_to_date): - frappe.throw(_("Application period cannot be outside leave allocation period")) - - elif allocation_based_on_from_date != allocation_based_on_to_date: - frappe.throw(_("Application period cannot be across two allocation records")) + return allocation_based_on_from_date, allocation_based_on_to_date def validate_back_dated_application(self): future_allocation = frappe.db.sql("""select name, from_date from `tabLeave Allocation` @@ -261,15 +280,29 @@ class LeaveApplication(Document): frappe.throw(_("The day(s) on which you are applying for leave are holidays. You need not apply for leave.")) if not is_lwp(self.leave_type): - self.leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date, - consider_all_leaves_in_the_allocation_period=True) - if self.status != "Rejected" and (self.leave_balance < self.total_leave_days or not self.leave_balance): - if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"): - frappe.msgprint(_("Note: There is not enough leave balance for Leave Type {0}") - .format(self.leave_type)) - else: - frappe.throw(_("There is not enough leave balance for Leave Type {0}") - .format(self.leave_type)) + leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date, + consider_all_leaves_in_the_allocation_period=True, for_consumption=True) + self.leave_balance = leave_balance.get("leave_balance") + leave_balance_for_consumption = leave_balance.get("leave_balance_for_consumption") + + if self.status != "Rejected" and (leave_balance_for_consumption < self.total_leave_days or not leave_balance_for_consumption): + self.show_insufficient_balance_message(leave_balance_for_consumption) + + def show_insufficient_balance_message(self, leave_balance_for_consumption: float) -> None: + alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates() + + if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"): + if leave_balance_for_consumption != self.leave_balance: + msg = _("Warning: Insufficient leave balance for Leave Type {0} in this allocation.").format(frappe.bold(self.leave_type)) + msg += "

" + msg += _("Actual balances aren't available because the leave application spans over different leave allocations. You can still apply for leaves which would be compensated during the next allocation.") + else: + msg = _("Warning: Insufficient leave balance for Leave Type {0}.").format(frappe.bold(self.leave_type)) + + frappe.msgprint(msg, title=_("Warning"), indicator="orange") + else: + frappe.throw(_("Insufficient leave balance for Leave Type {0}").format(frappe.bold(self.leave_type)), + exc=InsufficientLeaveBalanceError, title=_("Insufficient Balance")) def validate_leave_overlap(self): if not self.name: @@ -426,54 +459,111 @@ class LeaveApplication(Document): if self.status != 'Approved' and submit: return - expiry_date = get_allocation_expiry(self.employee, self.leave_type, + expiry_date = get_allocation_expiry_for_cf_leaves(self.employee, self.leave_type, self.to_date, self.from_date) - lwp = frappe.db.get_value("Leave Type", self.leave_type, "is_lwp") if expiry_date: self.create_ledger_entry_for_intermediate_allocation_expiry(expiry_date, submit, lwp) else: - raise_exception = True - if frappe.flags.in_patch: - raise_exception=False + alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates() + if self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date): + # required only if negative balance is allowed for leave type + # else will be stopped in validation itself + self.create_separate_ledger_entries(alloc_on_from_date, alloc_on_to_date, submit, lwp) + else: + raise_exception = False if frappe.flags.in_patch else True + args = dict( + leaves=self.total_leave_days * -1, + from_date=self.from_date, + to_date=self.to_date, + is_lwp=lwp, + holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' + ) + create_leave_ledger_entry(self, args, submit) - args = dict( - leaves=self.total_leave_days * -1, + def is_separate_ledger_entry_required(self, alloc_on_from_date: Optional[Dict] = None, alloc_on_to_date: Optional[Dict] = None) -> bool: + """Checks if application dates fall in separate allocations""" + if ((alloc_on_from_date and not alloc_on_to_date) + or (not alloc_on_from_date and alloc_on_to_date) + or (alloc_on_from_date and alloc_on_to_date and alloc_on_from_date.name != alloc_on_to_date.name)): + return True + return False + + def create_separate_ledger_entries(self, alloc_on_from_date, alloc_on_to_date, submit, lwp): + """Creates separate ledger entries for application period falling into separate allocations""" + # for creating separate ledger entries existing allocation periods should be consecutive + if submit and alloc_on_from_date and alloc_on_to_date and add_days(alloc_on_from_date.to_date, 1) != alloc_on_to_date.from_date: + frappe.throw(_("Leave Application period cannot be across two non-consecutive leave allocations {0} and {1}.").format( + get_link_to_form("Leave Allocation", alloc_on_from_date.name), get_link_to_form("Leave Allocation", alloc_on_to_date))) + + raise_exception = False if frappe.flags.in_patch else True + + if alloc_on_from_date: + first_alloc_end = alloc_on_from_date.to_date + second_alloc_start = add_days(alloc_on_from_date.to_date, 1) + else: + first_alloc_end = add_days(alloc_on_to_date.from_date, -1) + second_alloc_start = alloc_on_to_date.from_date + + leaves_in_first_alloc = get_number_of_leave_days(self.employee, self.leave_type, + self.from_date, first_alloc_end, self.half_day, self.half_day_date) + leaves_in_second_alloc = get_number_of_leave_days(self.employee, self.leave_type, + second_alloc_start, self.to_date, self.half_day, self.half_day_date) + + args = dict( + is_lwp=lwp, + holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' + ) + + if leaves_in_first_alloc: + args.update(dict( from_date=self.from_date, + to_date=first_alloc_end, + leaves=leaves_in_first_alloc * -1 + )) + create_leave_ledger_entry(self, args, submit) + + if leaves_in_second_alloc: + args.update(dict( + from_date=second_alloc_start, to_date=self.to_date, + leaves=leaves_in_second_alloc * -1 + )) + create_leave_ledger_entry(self, args, submit) + + def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp): + """Splits leave application into two ledger entries to consider expiry of allocation""" + raise_exception = False if frappe.flags.in_patch else True + + leaves = get_number_of_leave_days(self.employee, self.leave_type, + self.from_date, expiry_date, self.half_day, self.half_day_date) + + if leaves: + args = dict( + from_date=self.from_date, + to_date=expiry_date, + leaves=leaves * -1, is_lwp=lwp, holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' ) create_leave_ledger_entry(self, args, submit) - def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp): - ''' splits leave application into two ledger entries to consider expiry of allocation ''' - - raise_exception = True - if frappe.flags.in_patch: - raise_exception=False - - args = dict( - from_date=self.from_date, - to_date=expiry_date, - leaves=(date_diff(expiry_date, self.from_date) + 1) * -1, - is_lwp=lwp, - holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' - ) - create_leave_ledger_entry(self, args, submit) - if getdate(expiry_date) != getdate(self.to_date): start_date = add_days(expiry_date, 1) - args.update(dict( - from_date=start_date, - to_date=self.to_date, - leaves=date_diff(self.to_date, expiry_date) * -1 - )) - create_leave_ledger_entry(self, args, submit) + leaves = get_number_of_leave_days(self.employee, self.leave_type, + start_date, self.to_date, self.half_day, self.half_day_date) + + if leaves: + args.update(dict( + from_date=start_date, + to_date=self.to_date, + leaves=leaves * -1 + )) + create_leave_ledger_entry(self, args, submit) -def get_allocation_expiry(employee, leave_type, to_date, from_date): +def get_allocation_expiry_for_cf_leaves(employee: str, leave_type: str, to_date: str, from_date: str) -> str: ''' Returns expiry of carry forward allocation in leave ledger entry ''' expiry = frappe.get_all("Leave Ledger Entry", filters={ @@ -481,12 +571,17 @@ def get_allocation_expiry(employee, leave_type, to_date, from_date): 'leave_type': leave_type, 'is_carry_forward': 1, 'transaction_type': 'Leave Allocation', - 'to_date': ['between', (from_date, to_date)] + 'to_date': ['between', (from_date, to_date)], + 'docstatus': 1 },fields=['to_date']) - return expiry[0]['to_date'] if expiry else None + return expiry[0]['to_date'] if expiry else '' + @frappe.whitelist() -def get_number_of_leave_days(employee, leave_type, from_date, to_date, half_day = None, half_day_date = None, holiday_list = None): +def get_number_of_leave_days(employee: str, leave_type: str, from_date: str, to_date: str, half_day: Optional[int] = None, + half_day_date: Optional[str] = None, holiday_list: Optional[str] = None) -> float: + """Returns number of leave days between 2 dates after considering half day and holidays + (Based on the include_holiday setting in Leave Type)""" number_of_days = 0 if cint(half_day) == 1: if from_date == to_date: @@ -503,6 +598,7 @@ def get_number_of_leave_days(employee, leave_type, from_date, to_date, half_day number_of_days = flt(number_of_days) - flt(get_holidays(employee, from_date, to_date, holiday_list=holiday_list)) return number_of_days + @frappe.whitelist() def get_leave_details(employee, date): allocation_records = get_leave_allocation_records(employee, date) @@ -515,6 +611,7 @@ def get_leave_details(employee, date): 'to_date': ('>=', date), 'employee': employee, 'leave_type': allocation.leave_type, + 'docstatus': 1 }, 'SUM(total_leaves_allocated)') or 0 remaining_leaves = get_leave_balance_on(employee, d, date, to_date = allocation.to_date, @@ -522,29 +619,28 @@ def get_leave_details(employee, date): end_date = allocation.to_date leaves_taken = get_leaves_for_period(employee, d, allocation.from_date, end_date) * -1 - leaves_pending = get_pending_leaves_for_period(employee, d, allocation.from_date, end_date) + leaves_pending = get_leaves_pending_approval_for_period(employee, d, allocation.from_date, end_date) leave_allocation[d] = { "total_leaves": total_allocated_leaves, "expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken), "leaves_taken": leaves_taken, - "pending_leaves": leaves_pending, + "leaves_pending_approval": leaves_pending, "remaining_leaves": remaining_leaves} #is used in set query - lwps = frappe.get_list("Leave Type", filters = {"is_lwp": 1}) - lwps = [lwp.name for lwp in lwps] + lwp = frappe.get_list("Leave Type", filters={"is_lwp": 1}, pluck="name") - ret = { - 'leave_allocation': leave_allocation, - 'leave_approver': get_leave_approver(employee), - 'lwps': lwps + return { + "leave_allocation": leave_allocation, + "leave_approver": get_leave_approver(employee), + "lwps": lwp } - return ret @frappe.whitelist() -def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_leaves_in_the_allocation_period=False): +def get_leave_balance_on(employee: str, leave_type: str, date: str, to_date: str = None, + consider_all_leaves_in_the_allocation_period: bool = False, for_consumption: bool = False): ''' Returns leave balance till date :param employee: employee name @@ -552,6 +648,11 @@ def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_ :param date: date to check balance on :param to_date: future date to check for allocation expiry :param consider_all_leaves_in_the_allocation_period: consider all leaves taken till the allocation end date + :param for_consumption: flag to check if leave balance is required for consumption or display + eg: employee has leave balance = 10 but allocation is expiring in 1 day so employee can only consume 1 leave + in this case leave_balance = 10 but leave_balance_for_consumption = 1 + if True, returns a dict eg: {'leave_balance': 10, 'leave_balance_for_consumption': 1} + else, returns leave_balance (in this case 10) ''' if not to_date: @@ -561,11 +662,17 @@ def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_ allocation = allocation_records.get(leave_type, frappe._dict()) end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date - expiry = get_allocation_expiry(employee, leave_type, to_date, date) + cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date) leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date) - return get_remaining_leaves(allocation, leaves_taken, date, expiry) + remaining_leaves = get_remaining_leaves(allocation, leaves_taken, date, cf_expiry) + + if for_consumption: + return remaining_leaves + else: + return remaining_leaves.get('leave_balance') + def get_leave_allocation_records(employee, date, leave_type=None): """Returns the total allocated leaves and carry forwarded leaves based on ledger entries""" @@ -614,8 +721,9 @@ def get_leave_allocation_records(employee, date, leave_type=None): })) return allocated_leaves -def get_pending_leaves_for_period(employee, leave_type, from_date, to_date): - ''' Returns leaves that are pending approval ''' + +def get_leaves_pending_approval_for_period(employee: str, leave_type: str, from_date: str, to_date: str) -> float: + ''' Returns leaves that are pending for approval ''' leaves = frappe.get_all("Leave Application", filters={ "employee": employee, @@ -628,38 +736,46 @@ def get_pending_leaves_for_period(employee, leave_type, from_date, to_date): }, fields=['SUM(total_leave_days) as leaves'])[0] return leaves['leaves'] if leaves['leaves'] else 0.0 -def get_remaining_leaves(allocation, leaves_taken, date, expiry): - ''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry ''' - def _get_remaining_leaves(remaining_leaves, end_date): +def get_remaining_leaves(allocation: Dict, leaves_taken: float, date: str, cf_expiry: str) -> Dict[str, float]: + '''Returns a dict of leave_balance and leave_balance_for_consumption + leave_balance returns the available leave balance + leave_balance_for_consumption returns the minimum leaves remaining after comparing with remaining days for allocation expiry + ''' + def _get_remaining_leaves(remaining_leaves, end_date): + ''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry ''' if remaining_leaves > 0: remaining_days = date_diff(end_date, date) + 1 remaining_leaves = min(remaining_days, remaining_leaves) return remaining_leaves - total_leaves = flt(allocation.total_leaves_allocated) + flt(leaves_taken) + leave_balance = leave_balance_for_consumption = flt(allocation.total_leaves_allocated) + flt(leaves_taken) - if expiry and allocation.unused_leaves: - remaining_leaves = flt(allocation.unused_leaves) + flt(leaves_taken) - remaining_leaves = _get_remaining_leaves(remaining_leaves, expiry) + # balance for carry forwarded leaves + if cf_expiry and allocation.unused_leaves: + cf_leaves = flt(allocation.unused_leaves) + flt(leaves_taken) + remaining_cf_leaves = _get_remaining_leaves(cf_leaves, cf_expiry) - total_leaves = flt(allocation.new_leaves_allocated) + flt(remaining_leaves) + leave_balance = flt(allocation.new_leaves_allocated) + flt(cf_leaves) + leave_balance_for_consumption = flt(allocation.new_leaves_allocated) + flt(remaining_cf_leaves) - return _get_remaining_leaves(total_leaves, allocation.to_date) + remaining_leaves = _get_remaining_leaves(leave_balance_for_consumption, allocation.to_date) + return frappe._dict(leave_balance=leave_balance, leave_balance_for_consumption=remaining_leaves) -def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_expired_leaves=False): + +def get_leaves_for_period(employee: str, leave_type: str, from_date: str, to_date: str, skip_expired_leaves: bool = True) -> float: leave_entries = get_leave_entries(employee, leave_type, from_date, to_date) leave_days = 0 for leave_entry in leave_entries: inclusive_period = leave_entry.from_date >= getdate(from_date) and leave_entry.to_date <= getdate(to_date) - if inclusive_period and leave_entry.transaction_type == 'Leave Encashment': + if inclusive_period and leave_entry.transaction_type == 'Leave Encashment': leave_days += leave_entry.leaves elif inclusive_period and leave_entry.transaction_type == 'Leave Allocation' and leave_entry.is_expired \ - and (do_not_skip_expired_leaves or not skip_expiry_leaves(leave_entry, to_date)): + and not skip_expired_leaves: leave_days += leave_entry.leaves elif leave_entry.transaction_type == 'Leave Application': @@ -681,11 +797,6 @@ def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_ return leave_days -def skip_expiry_leaves(leave_entry, date): - ''' Checks whether the expired leaves coincide with the to_date of leave balance check. - This allows backdated leave entry creation for non carry forwarded allocation ''' - end_date = frappe.db.get_value("Leave Allocation", {'name': leave_entry.transaction_name}, ['to_date']) - return True if end_date == date and not leave_entry.is_carry_forward else False def get_leave_entries(employee, leave_type, from_date, to_date): ''' Returns leave entries between from_date and to_date. ''' @@ -708,6 +819,7 @@ def get_leave_entries(employee, leave_type, from_date, to_date): "leave_type": leave_type }, as_dict=1) + @frappe.whitelist() def get_holidays(employee, from_date, to_date, holiday_list = None): '''get holidays between two dates for the given employee''' @@ -724,6 +836,7 @@ def is_lwp(leave_type): lwp = frappe.db.sql("select is_lwp from `tabLeave Type` where name = %s", leave_type) return lwp and cint(lwp[0][0]) or 0 + @frappe.whitelist() def get_events(start, end, filters=None): from frappe.desk.reportview import get_filters_cond @@ -752,6 +865,7 @@ def get_events(start, end, filters=None): return events + def add_department_leaves(events, start, end, employee, company): department = frappe.db.get_value("Employee", employee, "department") @@ -832,6 +946,7 @@ def add_block_dates(events, start, end, employee, company): }) cnt+=1 + def add_holidays(events, start, end, employee, company): applicable_holiday_list = get_holiday_list_for_employee(employee, company) if not applicable_holiday_list: @@ -848,6 +963,7 @@ def add_holidays(events, start, end, employee, company): "name": holiday.name }) + @frappe.whitelist() def get_mandatory_approval(doctype): mandatory = "" @@ -860,6 +976,7 @@ def get_mandatory_approval(doctype): return mandatory + def get_approved_leaves_for_period(employee, leave_type, from_date, to_date): query = """ select employee, leave_type, from_date, to_date, total_leave_days @@ -895,6 +1012,7 @@ def get_approved_leaves_for_period(employee, leave_type, from_date, to_date): return leave_days + @frappe.whitelist() def get_leave_approver(employee): leave_approver, department = frappe.db.get_value("Employee", diff --git a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html index 9f667a68356..e755322efda 100644 --- a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html +++ b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html @@ -4,11 +4,11 @@ {{ __("Leave Type") }} - {{ __("Total Allocated Leave") }} - {{ __("Expired Leave") }} - {{ __("Used Leave") }} - {{ __("Pending Leave") }} - {{ __("Available Leave") }} + {{ __("Total Allocated Leave(s)") }} + {{ __("Expired Leave(s)") }} + {{ __("Used Leave(s)") }} + {{ __("Leave(s) Pending Approval") }} + {{ __("Available Leave(s)") }} @@ -18,7 +18,7 @@ {%= value["total_leaves"] %} {%= value["expired_leaves"] %} {%= value["leaves_taken"] %} - {%= value["pending_leaves"] %} + {%= value["leaves_pending_approval"] %} {%= value["remaining_leaves"] %} {% } %} diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index c9f377dfaca..4f7fcee30fe 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -17,12 +17,17 @@ from frappe.utils import ( ) from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation from erpnext.hr.doctype.leave_application.leave_application import ( + InsufficientLeaveBalanceError, + LeaveAcrossAllocationsError, LeaveDayBlockedError, NotAnOptionalHoliday, OverlapError, + get_leave_allocation_records, get_leave_balance_on, + get_leave_details, ) from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( create_assignment_for_multiple_employees, @@ -33,7 +38,7 @@ from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( make_leave_application, ) -test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"] +test_dependencies = ["Leave Type", "Leave Allocation", "Leave Block List", "Employee"] _test_records = [ { @@ -72,15 +77,28 @@ _test_records = [ class TestLeaveApplication(unittest.TestCase): def setUp(self): for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]: - frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec + frappe.db.delete(dt) frappe.set_user("Administrator") set_leave_approver() - frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") + frappe.db.delete("Attendance", {"employee": "_T-Employee-00001"}) + frappe.db.set_value("Employee", "_T-Employee-00001", "holiday_list", "") + + from_date = get_year_start(getdate()) + to_date = get_year_ending(getdate()) + self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) + + if not frappe.db.exists("Leave Type", "_Test Leave Type"): + frappe.get_doc(dict( + leave_type_name="_Test Leave Type", + doctype="Leave Type", + include_holiday=True + )).insert() def tearDown(self): frappe.db.rollback() + frappe.set_user("Administrator") def _clear_roles(self): frappe.db.sql("""delete from `tabHas Role` where parent in @@ -95,6 +113,132 @@ class TestLeaveApplication(unittest.TestCase): application.to_date = "2013-01-05" return application + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_validate_application_across_allocations(self): + # Test validation for application dates when negative balance is disabled + frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1) + leave_type = frappe.get_doc(dict( + leave_type_name="Test Leave Validation", + doctype="Leave Type", + allow_negative=False + )).insert() + + employee = get_employee() + date = getdate() + first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date)) + + leave_application = frappe.get_doc(dict( + doctype='Leave Application', + employee=employee.name, + leave_type=leave_type.name, + from_date=add_days(first_sunday, 1), + to_date=add_days(first_sunday, 4), + company="_Test Company", + status="Approved", + leave_approver = 'test@example.com' + )) + # Application period cannot be outside leave allocation period + self.assertRaises(frappe.ValidationError, leave_application.insert) + + make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) + + leave_application = frappe.get_doc(dict( + doctype='Leave Application', + employee=employee.name, + leave_type=leave_type.name, + from_date=add_days(first_sunday, -10), + to_date=add_days(first_sunday, 1), + company="_Test Company", + status="Approved", + leave_approver = 'test@example.com' + )) + + # Application period cannot be across two allocation records + self.assertRaises(LeaveAcrossAllocationsError, leave_application.insert) + + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_insufficient_leave_balance_validation(self): + # CASE 1: Validation when allow negative is disabled + frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1) + leave_type = frappe.get_doc(dict( + leave_type_name="Test Leave Validation", + doctype="Leave Type", + allow_negative=False + )).insert() + + employee = get_employee() + date = getdate() + first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date)) + + # allocate 2 leaves, apply for more + make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date), leaves=2) + leave_application = frappe.get_doc(dict( + doctype='Leave Application', + employee=employee.name, + leave_type=leave_type.name, + from_date=add_days(first_sunday, 1), + to_date=add_days(first_sunday, 3), + company="_Test Company", + status="Approved", + leave_approver = 'test@example.com' + )) + self.assertRaises(InsufficientLeaveBalanceError, leave_application.insert) + + # CASE 2: Allows creating application with a warning message when allow negative is enabled + frappe.db.set_value("Leave Type", "Test Leave Validation", "allow_negative", True) + make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 3), leave_type.name) + + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_separate_leave_ledger_entry_for_boundary_applications(self): + # When application falls in 2 different allocations and Allow Negative is enabled + # creates separate leave ledger entries + frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1) + leave_type = frappe.get_doc(dict( + leave_type_name="Test Leave Validation", + doctype="Leave Type", + allow_negative=True + )).insert() + + employee = get_employee() + date = getdate() + year_start = getdate(get_year_start(date)) + year_end = getdate(get_year_ending(date)) + + make_allocation_record(leave_type=leave_type.name, from_date=year_start, to_date=year_end) + # application across allocations + + # CASE 1: from date has no allocation, to date has an allocation / both dates have allocation + application = make_leave_application(employee.name, add_days(year_start, -10), add_days(year_start, 3), leave_type.name) + + # 2 separate leave ledger entries + ledgers = frappe.db.get_all("Leave Ledger Entry", { + "transaction_type": "Leave Application", + "transaction_name": application.name + }, ["leaves", "from_date", "to_date"], order_by="from_date") + self.assertEqual(len(ledgers), 2) + + self.assertEqual(ledgers[0].from_date, application.from_date) + self.assertEqual(ledgers[0].to_date, add_days(year_start, -1)) + + self.assertEqual(ledgers[1].from_date, year_start) + self.assertEqual(ledgers[1].to_date, application.to_date) + + # CASE 2: from date has an allocation, to date has no allocation + application = make_leave_application(employee.name, add_days(year_end, -3), add_days(year_end, 5), leave_type.name) + + # 2 separate leave ledger entries + ledgers = frappe.db.get_all("Leave Ledger Entry", { + "transaction_type": "Leave Application", + "transaction_name": application.name + }, ["leaves", "from_date", "to_date"], order_by="from_date") + self.assertEqual(len(ledgers), 2) + + self.assertEqual(ledgers[0].from_date, application.from_date) + self.assertEqual(ledgers[0].to_date, year_end) + + self.assertEqual(ledgers[1].from_date, add_days(year_end, 1)) + self.assertEqual(ledgers[1].to_date, application.to_date) + def test_overwrite_attendance(self): '''check attendance is automatically created on leave approval''' make_allocation_record() @@ -119,6 +263,7 @@ class TestLeaveApplication(unittest.TestCase): for d in ('2018-01-01', '2018-01-02', '2018-01-03'): self.assertTrue(getdate(d) in dates) + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') def test_attendance_for_include_holidays(self): # Case 1: leave type with 'Include holidays within leaves as leaves' enabled frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1) @@ -131,12 +276,8 @@ class TestLeaveApplication(unittest.TestCase): date = getdate() make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) - holiday_list = make_holiday_list() employee = get_employee() - original_holiday_list = employee.holiday_list - frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list) - - first_sunday = get_first_sunday(holiday_list) + first_sunday = get_first_sunday(self.holiday_list) leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name) leave_application.reload() @@ -145,8 +286,7 @@ class TestLeaveApplication(unittest.TestCase): leave_application.cancel() - frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list) - + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') def test_attendance_update_for_exclude_holidays(self): # Case 2: leave type with 'Include holidays within leaves as leaves' disabled frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1) @@ -159,11 +299,8 @@ class TestLeaveApplication(unittest.TestCase): date = getdate() make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) - holiday_list = make_holiday_list() employee = get_employee() - original_holiday_list = employee.holiday_list - frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list) - first_sunday = get_first_sunday(holiday_list) + first_sunday = get_first_sunday(self.holiday_list) # already marked attendance on a holiday should be deleted in this case config = { @@ -184,6 +321,7 @@ class TestLeaveApplication(unittest.TestCase): leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name, employee.company) leave_application.reload() + # holiday should be excluded while marking attendance self.assertEqual(leave_application.total_leave_days, 3) self.assertEqual(frappe.db.count("Attendance", {"leave_application": leave_application.name}), 3) @@ -194,8 +332,6 @@ class TestLeaveApplication(unittest.TestCase): # attendance on non-holiday updated self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave") - frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list) - def test_block_list(self): self._clear_roles() @@ -327,17 +463,14 @@ class TestLeaveApplication(unittest.TestCase): application.half_day_date = "2013-01-05" application.insert() + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') def test_optional_leave(self): leave_period = get_leave_period() today = nowdate() holiday_list = 'Test Holiday List for Optional Holiday' employee = get_employee() - default_holiday_list = make_holiday_list() - original_holiday_list = employee.holiday_list - frappe.db.set_value("Employee", employee.name, "holiday_list", default_holiday_list) - first_sunday = get_first_sunday(default_holiday_list) - + first_sunday = get_first_sunday(self.holiday_list) optional_leave_date = add_days(first_sunday, 1) if not frappe.db.exists('Holiday List', holiday_list): @@ -386,8 +519,6 @@ class TestLeaveApplication(unittest.TestCase): # check leave balance is reduced self.assertEqual(get_leave_balance_on(employee.name, leave_type, optional_leave_date), 9) - frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list) - def test_leaves_allowed(self): employee = get_employee() leave_period = get_leave_period() @@ -513,11 +644,13 @@ class TestLeaveApplication(unittest.TestCase): leave_type_name="_Test_CF_leave_expiry", is_carry_forward=1, expire_carry_forwarded_leaves_after_days=90) - leave_type.submit() + leave_type.insert() create_carry_forwarded_allocation(employee, leave_type) + details = get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8), for_consumption=True) - self.assertEqual(get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8)), 21) + self.assertEqual(details.leave_balance_for_consumption, 21) + self.assertEqual(details.leave_balance, 30) def test_earned_leaves_creation(self): @@ -570,7 +703,14 @@ class TestLeaveApplication(unittest.TestCase): # test to not consider current leave in leave balance while submitting def test_current_leave_on_submit(self): employee = get_employee() - leave_type = 'Sick leave' + + leave_type = 'Sick Leave' + if not frappe.db.exists('Leave Type', leave_type): + frappe.get_doc(dict( + leave_type_name=leave_type, + doctype='Leave Type' + )).insert() + allocation = frappe.get_doc(dict( doctype = 'Leave Allocation', employee = employee.name, @@ -713,6 +853,56 @@ class TestLeaveApplication(unittest.TestCase): employee.leave_approver = "" employee.save() + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_get_leave_details_for_dashboard(self): + employee = get_employee() + date = getdate() + year_start = getdate(get_year_start(date)) + year_end = getdate(get_year_ending(date)) + + # ALLOCATION = 30 + allocation = make_allocation_record(employee=employee.name, from_date=year_start, to_date=year_end) + + # USED LEAVES = 4 + first_sunday = get_first_sunday(self.holiday_list) + leave_application = make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type') + leave_application.reload() + + # LEAVES PENDING APPROVAL = 1 + leave_application = make_leave_application(employee.name, add_days(first_sunday, 5), add_days(first_sunday, 5), + '_Test Leave Type', submit=False) + leave_application.status = 'Open' + leave_application.save() + + details = get_leave_details(employee.name, allocation.from_date) + leave_allocation = details['leave_allocation']['_Test Leave Type'] + self.assertEqual(leave_allocation['total_leaves'], 30) + self.assertEqual(leave_allocation['leaves_taken'], 4) + self.assertEqual(leave_allocation['expired_leaves'], 0) + self.assertEqual(leave_allocation['leaves_pending_approval'], 1) + self.assertEqual(leave_allocation['remaining_leaves'], 26) + + @set_holiday_list('Salary Slip Test Holiday List', '_Test Company') + def test_get_leave_allocation_records(self): + employee = get_employee() + leave_type = create_leave_type( + leave_type_name="_Test_CF_leave_expiry", + is_carry_forward=1, + expire_carry_forwarded_leaves_after_days=90) + leave_type.insert() + + leave_alloc = create_carry_forwarded_allocation(employee, leave_type) + details = get_leave_allocation_records(employee.name, getdate(), leave_type.name) + expected_data = { + "from_date": getdate(leave_alloc.from_date), + "to_date": getdate(leave_alloc.to_date), + "total_leaves_allocated": 30.0, + "unused_leaves": 15.0, + "new_leaves_allocated": 15.0, + "leave_type": leave_type.name + } + self.assertEqual(details.get(leave_type.name), expected_data) + def create_carry_forwarded_allocation(employee, leave_type): # initial leave allocation @@ -734,19 +924,24 @@ def create_carry_forwarded_allocation(employee, leave_type): carry_forward=1) leave_allocation.submit() -def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None): + return leave_allocation + +def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None, carry_forward=False, leaves=None): allocation = frappe.get_doc({ "doctype": "Leave Allocation", "employee": employee or "_T-Employee-00001", "leave_type": leave_type or "_Test Leave Type", "from_date": from_date or "2013-01-01", "to_date": to_date or "2019-12-31", - "new_leaves_allocated": 30 + "new_leaves_allocated": leaves or 30, + "carry_forward": carry_forward }) allocation.insert(ignore_permissions=True) allocation.submit() + return allocation + def get_employee(): return frappe.get_doc("Employee", "_T-Employee-00001") diff --git a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py index 5c5299ea7eb..a5923e0021c 100644 --- a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py +++ b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py @@ -171,7 +171,7 @@ def expire_carried_forward_allocation(allocation): ''' Expires remaining leaves in the on carried forward allocation ''' from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period leaves_taken = get_leaves_for_period(allocation.employee, allocation.leave_type, - allocation.from_date, allocation.to_date, do_not_skip_expired_leaves=True) + allocation.from_date, allocation.to_date, skip_expired_leaves=False) leaves = flt(allocation.leaves) + flt(leaves_taken) # allow expired leaves entry to be created 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 6e6943f71aa..ff1668003e7 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,14 +8,15 @@ 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_last_day, getdate +from frappe.utils import date_diff, flt, formatdate, get_last_day, get_link_to_form, getdate from six import string_types class LeavePolicyAssignment(Document): def validate(self): - self.validate_policy_assignment_overlap() self.set_dates() + self.validate_policy_assignment_overlap() + self.warn_about_carry_forwarding() def on_submit(self): self.grant_leave_alloc_for_employee() @@ -39,6 +40,20 @@ class LeavePolicyAssignment(Document): frappe.throw(_("Leave Policy: {0} already assigned for Employee {1} for period {2} to {3}") .format(bold(self.leave_policy), bold(self.employee), bold(formatdate(self.effective_from)), bold(formatdate(self.effective_to)))) + def warn_about_carry_forwarding(self): + if not self.carry_forward: + return + + leave_types = get_leave_type_details() + leave_policy = frappe.get_doc("Leave Policy", self.leave_policy) + + for policy in leave_policy.leave_policy_details: + leave_type = leave_types.get(policy.leave_type) + if not leave_type.is_carry_forward: + msg = _("Leaves for the Leave Type {0} won't be carry-forwarded since carry-forwarding is disabled.").format( + frappe.bold(get_link_to_form("Leave Type", leave_type.name))) + frappe.msgprint(msg, indicator="orange", alert=True) + @frappe.whitelist() def grant_leave_alloc_for_employee(self): if self.leaves_allocated: 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 8d7b27ee5af..08680425a02 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, get_last_day, getdate +from frappe.utils import add_days, add_months, get_first_day, get_last_day, getdate from erpnext.hr.doctype.leave_application.test_leave_application import ( get_employee, @@ -94,9 +94,12 @@ class TestLeavePolicyAssignment(unittest.TestCase): "leave_policy": leave_policy.name, "leave_period": leave_period.name } + + # second last day of the month + # leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency + frappe.flags.current_date = add_days(get_last_day(getdate()), -1) 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", { "leave_policy_assignment": leave_policy_assignments[0] }, "total_leaves_allocated") diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py index b375b18b079..66c1d25d593 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py @@ -3,18 +3,21 @@ from itertools import groupby +from typing import Dict, List, Optional, Tuple import frappe from frappe import _ -from frappe.utils import add_days +from frappe.utils import add_days, getdate +from erpnext.hr.doctype.leave_allocation.leave_allocation import get_previous_allocation from erpnext.hr.doctype.leave_application.leave_application import ( get_leave_balance_on, get_leaves_for_period, ) +Filters = frappe._dict -def execute(filters=None): +def execute(filters: Optional[Filters] = None) -> Tuple: if filters.to_date <= filters.from_date: frappe.throw(_('"From Date" can not be greater than or equal to "To Date"')) @@ -23,8 +26,9 @@ def execute(filters=None): charts = get_chart_data(data) return columns, data, None, charts -def get_columns(): - columns = [{ + +def get_columns() -> List[Dict]: + return [{ 'label': _('Leave Type'), 'fieldtype': 'Link', 'fieldname': 'leave_type', @@ -46,32 +50,31 @@ def get_columns(): 'label': _('Opening Balance'), 'fieldtype': 'float', 'fieldname': 'opening_balance', - 'width': 130, + 'width': 150, }, { - 'label': _('Leave Allocated'), + 'label': _('New Leave(s) Allocated'), 'fieldtype': 'float', 'fieldname': 'leaves_allocated', - 'width': 130, + 'width': 200, }, { - 'label': _('Leave Taken'), + 'label': _('Leave(s) Taken'), 'fieldtype': 'float', 'fieldname': 'leaves_taken', - 'width': 130, + 'width': 150, }, { - 'label': _('Leave Expired'), + 'label': _('Leave(s) Expired'), 'fieldtype': 'float', 'fieldname': 'leaves_expired', - 'width': 130, + 'width': 150, }, { 'label': _('Closing Balance'), 'fieldtype': 'float', 'fieldname': 'closing_balance', - 'width': 130, + 'width': 150, }] - return columns -def get_data(filters): +def get_data(filters: Filters) -> List: leave_types = frappe.db.get_list('Leave Type', pluck='name', order_by='name') conditions = get_conditions(filters) @@ -102,19 +105,18 @@ def get_data(filters): or ("HR Manager" in frappe.get_roles(user)): if len(active_employees) > 1: row = frappe._dict() - row.employee = employee.name, + row.employee = employee.name row.employee_name = employee.employee_name leaves_taken = get_leaves_for_period(employee.name, leave_type, filters.from_date, filters.to_date) * -1 - new_allocation, expired_leaves = get_allocated_and_expired_leaves(filters.from_date, filters.to_date, employee.name, leave_type) - - - opening = get_leave_balance_on(employee.name, leave_type, add_days(filters.from_date, -1)) #allocation boundary condition + new_allocation, expired_leaves, carry_forwarded_leaves = get_allocated_and_expired_leaves( + filters.from_date, filters.to_date, employee.name, leave_type) + opening = get_opening_balance(employee.name, leave_type, filters, carry_forwarded_leaves) row.leaves_allocated = new_allocation - row.leaves_expired = expired_leaves - leaves_taken if expired_leaves - leaves_taken > 0 else 0 + row.leaves_expired = expired_leaves row.opening_balance = opening row.leaves_taken = leaves_taken @@ -125,7 +127,26 @@ def get_data(filters): return data -def get_conditions(filters): + +def get_opening_balance(employee: str, leave_type: str, filters: Filters, carry_forwarded_leaves: float) -> float: + # allocation boundary condition + # opening balance is the closing leave balance 1 day before the filter start date + opening_balance_date = add_days(filters.from_date, -1) + allocation = get_previous_allocation(filters.from_date, leave_type, employee) + + if allocation and allocation.get("to_date") and opening_balance_date and \ + getdate(allocation.get("to_date")) == getdate(opening_balance_date): + # if opening balance date is same as the previous allocation's expiry + # then opening balance should only consider carry forwarded leaves + opening_balance = carry_forwarded_leaves + else: + # else directly get leave balance on the previous day + opening_balance = get_leave_balance_on(employee, leave_type, opening_balance_date) + + return opening_balance + + +def get_conditions(filters: Filters) -> Dict: conditions={ 'status': 'Active', } @@ -140,29 +161,26 @@ def get_conditions(filters): return conditions -def get_department_leave_approver_map(department=None): +def get_department_leave_approver_map(department: Optional[str] = None): # get current department and all its child department_list = frappe.get_list('Department', - filters={ - 'disabled': 0 - }, - or_filters={ - 'name': department, - 'parent_department': department - }, - fields=['name'], - pluck='name' - ) + filters={'disabled': 0}, + or_filters={ + 'name': department, + 'parent_department': department + }, + pluck='name' + ) # retrieve approvers list from current department and from its subsequent child departments approver_list = frappe.get_all('Department Approver', - filters={ - 'parentfield': 'leave_approvers', - 'parent': ('in', department_list) - }, - fields=['parent', 'approver'], - as_list=1 - ) + filters={ + 'parentfield': 'leave_approvers', + 'parent': ('in', department_list) + }, + fields=['parent', 'approver'], + as_list=True + ) approvers = {} @@ -171,41 +189,61 @@ def get_department_leave_approver_map(department=None): return approvers -def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type): - - from frappe.utils import getdate +def get_allocated_and_expired_leaves(from_date: str, to_date: str, employee: str, leave_type: str) -> Tuple[float, float, float]: new_allocation = 0 expired_leaves = 0 + carry_forwarded_leaves = 0 - records= frappe.db.sql(""" - SELECT - employee, leave_type, from_date, to_date, leaves, transaction_name, - transaction_type, is_carry_forward, is_expired - FROM `tabLeave Ledger Entry` - WHERE employee=%(employee)s AND leave_type=%(leave_type)s - AND docstatus=1 - AND transaction_type = 'Leave Allocation' - AND (from_date between %(from_date)s AND %(to_date)s - OR to_date between %(from_date)s AND %(to_date)s - OR (from_date < %(from_date)s AND to_date > %(to_date)s)) - """, { - "from_date": from_date, - "to_date": to_date, - "employee": employee, - "leave_type": leave_type - }, as_dict=1) + records = get_leave_ledger_entries(from_date, to_date, employee, leave_type) for record in records: + # new allocation records with `is_expired=1` are created when leave expires + # these new records should not be considered, else it leads to negative leave balance + if record.is_expired: + continue + if record.to_date < getdate(to_date): + # leave allocations ending before to_date, reduce leaves taken within that period + # since they are already used, they won't expire expired_leaves += record.leaves + expired_leaves += get_leaves_for_period(employee, leave_type, + record.from_date, record.to_date) if record.from_date >= getdate(from_date): - new_allocation += record.leaves + if record.is_carry_forward: + carry_forwarded_leaves += record.leaves + else: + new_allocation += record.leaves - return new_allocation, expired_leaves + return new_allocation, expired_leaves, carry_forwarded_leaves -def get_chart_data(data): + +def get_leave_ledger_entries(from_date: str, to_date: str, employee: str, leave_type: str) -> List[Dict]: + ledger = frappe.qb.DocType('Leave Ledger Entry') + records = ( + frappe.qb.from_(ledger) + .select( + ledger.employee, ledger.leave_type, ledger.from_date, ledger.to_date, + ledger.leaves, ledger.transaction_name, ledger.transaction_type, + ledger.is_carry_forward, ledger.is_expired + ).where( + (ledger.docstatus == 1) + & (ledger.transaction_type == 'Leave Allocation') + & (ledger.employee == employee) + & (ledger.leave_type == leave_type) + & ( + (ledger.from_date[from_date: to_date]) + | (ledger.to_date[from_date: to_date]) + | ((ledger.from_date < from_date) & (ledger.to_date > to_date)) + ) + ) + ).run(as_dict=True) + + return records + + +def get_chart_data(data: List) -> Dict: labels = [] datasets = [] employee_data = data @@ -224,7 +262,8 @@ def get_chart_data(data): return chart -def get_dataset_for_chart(employee_data, datasets, labels): + +def get_dataset_for_chart(employee_data: List, datasets: List, labels: List) -> List: leaves = [] employee_data = sorted(employee_data, key=lambda k: k['employee_name']) diff --git a/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py new file mode 100644 index 00000000000..b2ed72c04d7 --- /dev/null +++ b/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py @@ -0,0 +1,161 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + + +import unittest + +import frappe +from frappe.utils import add_days, add_months, flt, get_year_ending, get_year_start, getdate + +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list +from erpnext.hr.doctype.leave_application.test_leave_application import ( + get_first_sunday, + make_allocation_record, +) +from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation +from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type +from erpnext.hr.report.employee_leave_balance.employee_leave_balance import execute +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_holiday_list, + make_leave_application, +) + +test_records = frappe.get_test_records('Leave Type') + +class TestEmployeeLeaveBalance(unittest.TestCase): + def setUp(self): + for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']: + frappe.db.delete(dt) + + frappe.set_user('Administrator') + + self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company') + + self.date = getdate() + self.year_start = getdate(get_year_start(self.date)) + self.mid_year = add_months(self.year_start, 6) + self.year_end = getdate(get_year_ending(self.date)) + + self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end) + + def tearDown(self): + frappe.db.rollback() + + @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company') + def test_employee_leave_balance(self): + frappe.get_doc(test_records[0]).insert() + + # 5 leaves + allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11), + to_date=add_days(self.year_start, -1), leaves=5) + # 30 leaves + allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end) + # expires 5 leaves + process_expired_allocation() + + # 4 days leave + first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) + leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type') + leave_application.reload() + + filters = frappe._dict({ + 'from_date': allocation1.from_date, + 'to_date': allocation2.to_date, + 'employee': self.employee_id + }) + + report = execute(filters) + + expected_data = [{ + 'leave_type': '_Test Leave Type', + 'employee': self.employee_id, + 'employee_name': 'test_emp_leave_balance@example.com', + 'leaves_allocated': flt(allocation1.new_leaves_allocated + allocation2.new_leaves_allocated), + 'leaves_expired': flt(allocation1.new_leaves_allocated), + 'opening_balance': flt(0), + 'leaves_taken': flt(leave_application.total_leave_days), + 'closing_balance': flt(allocation2.new_leaves_allocated - leave_application.total_leave_days), + 'indent': 1 + }] + + self.assertEqual(report[1], expected_data) + + @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company') + def test_opening_balance_on_alloc_boundary_dates(self): + frappe.get_doc(test_records[0]).insert() + + # 30 leaves allocated + allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end) + # 4 days leave application in the first allocation + first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) + leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type') + leave_application.reload() + + # Case 1: opening balance for first alloc boundary + filters = frappe._dict({ + 'from_date': self.year_start, + 'to_date': self.year_end, + 'employee': self.employee_id + }) + report = execute(filters) + self.assertEqual(report[1][0].opening_balance, 0) + + # Case 2: opening balance after leave application date + filters = frappe._dict({ + 'from_date': add_days(leave_application.to_date, 1), + 'to_date': self.year_end, + 'employee': self.employee_id + }) + report = execute(filters) + self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days)) + + # Case 3: leave balance shows actual balance and not consumption balance as per remaining days near alloc end date + # eg: 3 days left for alloc to end, leave balance should still be 26 and not 3 + filters = frappe._dict({ + 'from_date': add_days(self.year_end, -3), + 'to_date': self.year_end, + 'employee': self.employee_id + }) + report = execute(filters) + self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days)) + + @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company') + def test_opening_balance_considers_carry_forwarded_leaves(self): + leave_type = create_leave_type( + leave_type_name="_Test_CF_leave_expiry", + is_carry_forward=1) + leave_type.insert() + + # 30 leaves allocated for first half of the year + allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, + to_date=self.mid_year, leave_type=leave_type.name) + # 4 days leave application in the first allocation + first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) + leave_application = make_leave_application(self.employee_id, first_sunday, add_days(first_sunday, 3), leave_type.name) + leave_application.reload() + # 30 leaves allocated for second half of the year + carry forward leaves (26) from the previous allocation + allocation2 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.mid_year, 1), to_date=self.year_end, + carry_forward=True, leave_type=leave_type.name) + + # Case 1: carry forwarded leaves considered in opening balance for second alloc + filters = frappe._dict({ + 'from_date': add_days(self.mid_year, 1), + 'to_date': self.year_end, + 'employee': self.employee_id + }) + report = execute(filters) + # available leaves from old alloc + opening_balance = allocation1.new_leaves_allocated - leave_application.total_leave_days + self.assertEqual(report[1][0].opening_balance, opening_balance) + + # Case 2: opening balance one day after alloc boundary = carry forwarded leaves + new leaves alloc + filters = frappe._dict({ + 'from_date': add_days(self.mid_year, 2), + 'to_date': self.year_end, + 'employee': self.employee_id + }) + report = execute(filters) + # available leaves from old alloc + opening_balance = allocation2.new_leaves_allocated + (allocation1.new_leaves_allocated - leave_application.total_leave_days) + self.assertEqual(report[1][0].opening_balance, opening_balance) diff --git a/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py b/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py new file mode 100644 index 00000000000..6f16a8d58cb --- /dev/null +++ b/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py @@ -0,0 +1,117 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + + +import unittest + +import frappe +from frappe.utils import add_days, flt, get_year_ending, get_year_start, getdate + +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list +from erpnext.hr.doctype.leave_application.test_leave_application import ( + get_first_sunday, + make_allocation_record, +) +from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation +from erpnext.hr.report.employee_leave_balance_summary.employee_leave_balance_summary import execute +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_holiday_list, + make_leave_application, +) + +test_records = frappe.get_test_records('Leave Type') + +class TestEmployeeLeaveBalance(unittest.TestCase): + def setUp(self): + for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']: + frappe.db.delete(dt) + + frappe.set_user('Administrator') + + self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company') + self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company') + + self.date = getdate() + self.year_start = getdate(get_year_start(self.date)) + self.year_end = getdate(get_year_ending(self.date)) + + self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end) + + def tearDown(self): + frappe.db.rollback() + + @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company') + def test_employee_leave_balance_summary(self): + frappe.get_doc(test_records[0]).insert() + + # 5 leaves + allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11), + to_date=add_days(self.year_start, -1), leaves=5) + # 30 leaves + allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end) + + # 2 days leave within the first allocation + leave_application1 = make_leave_application(self.employee_id, add_days(self.year_start, -11), add_days(self.year_start, -10), + '_Test Leave Type') + leave_application1.reload() + + # expires 3 leaves + process_expired_allocation() + + # 4 days leave within the second allocation + first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) + leave_application2 = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type') + leave_application2.reload() + + filters = frappe._dict({ + 'date': add_days(leave_application2.to_date, 1), + 'company': '_Test Company', + 'employee': self.employee_id + }) + + report = execute(filters) + + expected_data = [[ + self.employee_id, + 'test_emp_leave_balance@example.com', + frappe.db.get_value('Employee', self.employee_id, 'department'), + flt( + allocation1.new_leaves_allocated # allocated = 5 + + allocation2.new_leaves_allocated # allocated = 30 + - leave_application1.total_leave_days # leaves taken in the 1st alloc = 2 + - (allocation1.new_leaves_allocated - leave_application1.total_leave_days) # leaves expired from 1st alloc = 3 + - leave_application2.total_leave_days # leaves taken in the 2nd alloc = 4 + ) + ]] + + self.assertEqual(report[1], expected_data) + + @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company') + def test_get_leave_balance_near_alloc_expiry(self): + frappe.get_doc(test_records[0]).insert() + + # 30 leaves allocated + allocation = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end) + # 4 days leave application in the first allocation + first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) + leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type') + leave_application.reload() + + # Leave balance should show actual balance, and not "consumption balance as per remaining days", near alloc end date + # eg: 3 days left for alloc to end, leave balance should still be 26 and not 3 + filters = frappe._dict({ + 'date': add_days(self.year_end, -3), + 'company': '_Test Company', + 'employee': self.employee_id + }) + report = execute(filters) + + expected_data = [[ + self.employee_id, + 'test_emp_leave_balance@example.com', + frappe.db.get_value('Employee', self.employee_id, 'department'), + flt(allocation.new_leaves_allocated - leave_application.total_leave_days) + ]] + + self.assertEqual(report[1], expected_data) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index eff2344e85c..d4d337d8412 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -1,15 +1,15 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_months, today from erpnext import get_company_currency -from erpnext.tests.utils import ERPNextTestCase from .blanket_order import make_order -class TestBlanketOrder(ERPNextTestCase): +class TestBlanketOrder(FrappeTestCase): def setUp(self): frappe.flags.args = frappe._dict() diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index cde25a2bc22..797115abb27 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import functools +import re from collections import deque from operator import itemgetter from typing import List @@ -103,25 +104,33 @@ class BOM(WebsiteGenerator): ) def autoname(self): - names = frappe.db.sql_list("""select name from `tabBOM` where item=%s""", self.item) + # ignore amended documents while calculating current index + existing_boms = frappe.get_all( + "BOM", + filters={"item": self.item, "amended_from": ["is", "not set"]}, + pluck="name" + ) - if names: - # name can be BOM/ITEM/001, BOM/ITEM/001-1, BOM-ITEM-001, BOM-ITEM-001-1 - - # split by item - names = [name.split(self.item, 1) for name in names] - names = [d[-1][1:] for d in filter(lambda x: len(x) > 1 and x[-1], names)] - - # split by (-) if cancelled - if names: - names = [cint(name.split('-')[-1]) for name in names] - idx = max(names) + 1 - else: - idx = 1 + if existing_boms: + index = self.get_next_version_index(existing_boms) else: - idx = 1 + index = 1 + + prefix = self.doctype + suffix = "%.3i" % index # convert index to string (1 -> "001") + bom_name = f"{prefix}-{self.item}-{suffix}" + + if len(bom_name) <= 140: + name = bom_name + else: + # since max characters for name is 140, remove enough characters from the + # item name to fit the prefix, suffix and the separators + truncated_length = 140 - (len(prefix) + len(suffix) + 2) + truncated_item_name = self.item[:truncated_length] + # if a partial word is found after truncate, remove the extra characters + truncated_item_name = truncated_item_name.rsplit(" ", 1)[0] + name = f"{prefix}-{truncated_item_name}-{suffix}" - name = 'BOM-' + self.item + ('-%.3i' % idx) if frappe.db.exists("BOM", name): conflicting_bom = frappe.get_doc("BOM", name) @@ -134,6 +143,26 @@ class BOM(WebsiteGenerator): self.name = name + @staticmethod + def get_next_version_index(existing_boms: List[str]) -> int: + # split by "/" and "-" + delimiters = ["/", "-"] + pattern = "|".join(map(re.escape, delimiters)) + bom_parts = [re.split(pattern, bom_name) for bom_name in existing_boms] + + # filter out BOMs that do not follow the following formats: BOM/ITEM/001, BOM-ITEM-001 + valid_bom_parts = list(filter(lambda x: len(x) > 1 and x[-1], bom_parts)) + + # extract the current index from the BOM parts + if valid_bom_parts: + # handle cancelled and submitted documents + indexes = [cint(part[-1]) for part in valid_bom_parts] + index = max(indexes) + 1 + else: + index = 1 + + return index + def validate(self): self.route = frappe.scrub(self.name).replace('_', '-') @@ -141,6 +170,7 @@ class BOM(WebsiteGenerator): frappe.throw(_("Please select a Company first."), title=_("Mandatory")) self.clear_operations() + self.clear_inspection() self.validate_main_item() self.validate_currency() self.set_conversion_rate() @@ -387,6 +417,10 @@ class BOM(WebsiteGenerator): if not self.with_operations: self.set('operations', []) + def clear_inspection(self): + if not self.inspection_required: + self.quality_inspection_template = None + def validate_main_item(self): """ Validate main FG item""" item = self.get_item_det(self.item) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index bfafacdfb57..e9fb4862a05 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -6,7 +6,7 @@ from collections import deque from functools import partial import frappe -from frappe.test_runner import make_test_records +from frappe.tests.utils import FrappeTestCase from frappe.utils import cstr, flt from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order @@ -17,15 +17,11 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, ) from erpnext.tests.test_subcontracting import set_backflush_based_on -from erpnext.tests.utils import ERPNextTestCase test_records = frappe.get_test_records('BOM') +test_dependencies = ["Item", "Quality Inspection Template"] -class TestBOM(ERPNextTestCase): - def setUp(self): - if not frappe.get_value('Item', '_Test Item'): - make_test_records('Item') - +class TestBOM(FrappeTestCase): def test_get_items(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict items_dict = get_bom_items_as_dict(bom=get_default_bom(), @@ -385,6 +381,87 @@ class TestBOM(ERPNextTestCase): self.assertEqual(bom.transfer_material_against, "Work Order") bom.delete() + def test_bom_name_length(self): + """ test >140 char names""" + bom_tree = { + "x" * 140 : { + " ".join(["abc"] * 35): {} + } + } + create_nested_bom(bom_tree, prefix="") + + def test_version_index(self): + + bom = frappe.new_doc("BOM") + + version_index_test_cases = [ + (1, []), + (1, ["BOM#XYZ"]), + (2, ["BOM/ITEM/001"]), + (2, ["BOM-ITEM-001"]), + (3, ["BOM-ITEM-001", "BOM-ITEM-002"]), + (4, ["BOM-ITEM-001", "BOM-ITEM-002", "BOM-ITEM-003"]), + ] + + for expected_index, existing_boms in version_index_test_cases: + with self.subTest(): + self.assertEqual(expected_index, bom.get_next_version_index(existing_boms), + msg=f"Incorrect index for {existing_boms}") + + def test_bom_versioning(self): + bom_tree = { + frappe.generate_hash(length=10) : { + frappe.generate_hash(length=10): {} + } + } + bom = create_nested_bom(bom_tree, prefix="") + self.assertEqual(int(bom.name.split("-")[-1]), 1) + original_bom_name = bom.name + + bom.cancel() + bom.reload() + self.assertEqual(bom.name, original_bom_name) + + # create a new amendment + amendment = frappe.copy_doc(bom) + amendment.docstatus = 0 + amendment.amended_from = bom.name + + amendment.save() + amendment.submit() + amendment.reload() + + self.assertNotEqual(amendment.name, bom.name) + # `origname-001-1` version + self.assertEqual(int(amendment.name.split("-")[-1]), 1) + self.assertEqual(int(amendment.name.split("-")[-2]), 1) + + # create a new version + version = frappe.copy_doc(amendment) + version.docstatus = 0 + version.amended_from = None + version.save() + self.assertNotEqual(amendment.name, version.name) + self.assertEqual(int(version.name.split("-")[-1]), 2) + + def test_clear_inpection_quality(self): + + bom = frappe.copy_doc(test_records[2], ignore_no_copy=True) + bom.docstatus = 0 + bom.is_default = 0 + bom.quality_inspection_template = "_Test Quality Inspection Template" + bom.inspection_required = 1 + bom.save() + bom.reload() + + self.assertEqual(bom.quality_inspection_template, '_Test Quality Inspection Template') + + bom.inspection_required = 0 + bom.save() + bom.reload() + + self.assertEqual(bom.quality_inspection_template, None) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index 12576cbf322..b4c625d6108 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -2,15 +2,15 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.item.test_item import create_item -from erpnext.tests.utils import ERPNextTestCase test_records = frappe.get_test_records('BOM') -class TestBOMUpdateTool(ERPNextTestCase): +class TestBOMUpdateTool(FrappeTestCase): def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3c406156ebd..b5e16dd3c69 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -48,7 +48,7 @@ class JobCard(Document): self.validate_work_order() def set_sub_operations(self): - if self.operation: + if not self.sub_operations and self.operation: self.sub_operations = [] for row in frappe.get_all('Sub Operation', filters = {'parent': self.operation}, fields=['operation', 'idx'], order_by='idx'): @@ -500,6 +500,9 @@ class JobCard(Document): 2: "Cancelled" }[self.docstatus or 0] + if self.for_quantity <= self.transferred_qty: + self.status = 'Material Transferred' + if self.time_logs: self.status = 'Work In Progress' @@ -507,10 +510,6 @@ class JobCard(Document): (self.for_quantity <= self.total_completed_qty or not self.items)): self.status = 'Completed' - if self.status != 'Completed': - if self.for_quantity <= self.transferred_qty: - self.status = 'Material Transferred' - if update_status: self.db_set('status', self.status) diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index 8017209e7de..7f60bdc6d92 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -1,4 +1,5 @@ frappe.listview_settings['Job Card'] = { + has_indicator_for_draft: true, get_indicator: function(doc) { if (doc.status === "Work In Progress") { return [__("Work In Progress"), "orange", "status,=,Work In Progress"]; diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index bb5004ba86f..c5841c16f2d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import random_string from erpnext.manufacturing.doctype.job_card.job_card import OperationMismatchError, OverlapError @@ -11,10 +12,9 @@ from erpnext.manufacturing.doctype.job_card.job_card import ( from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.tests.utils import ERPNextTestCase -class TestJobCard(ERPNextTestCase): +class TestJobCard(FrappeTestCase): def setUp(self): make_bom_for_jc_tests() @@ -169,6 +169,7 @@ class TestJobCard(ERPNextTestCase): job_card_name = frappe.db.get_value("Job Card", {'work_order': self.work_order.name}) job_card = frappe.get_doc("Job Card", job_card_name) + self.assertEqual(job_card.status, "Open") # fully transfer both RMs transfer_entry_1 = make_stock_entry_from_jc(job_card_name) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index f3ded994814..5653e1be75d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -2,6 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on('Production Plan', { + + before_save: function(frm) { + // preserve temporary names on production plan item to re-link sub-assembly items + frm.doc.po_items.forEach(item => { + item.temporary_name = item.name; + }); + }, setup: function(frm) { frm.custom_make_buttons = { 'Work Order': 'Work Order / Subcontract PO', diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 56cf2b4f08a..23b32379413 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -189,7 +189,7 @@ "label": "Select Items to Manufacture" }, { - "depends_on": "get_items_from", + "depends_on": "eval:doc.get_items_from && doc.docstatus == 0", "fieldname": "get_items", "fieldtype": "Button", "label": "Get Finished Goods for Manufacture" @@ -197,6 +197,7 @@ { "fieldname": "po_items", "fieldtype": "Table", + "label": "Assembly Items", "no_copy": 1, "options": "Production Plan Item", "reqd": 1 @@ -357,6 +358,7 @@ "options": "Production Plan Sub Assembly Item" }, { + "depends_on": "eval:doc.po_items && doc.po_items.length && doc.docstatus == 0", "fieldname": "get_sub_assembly_items", "fieldtype": "Button", "label": "Get Sub Assembly Items" @@ -376,7 +378,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-09-06 18:35:59.642232", + "modified": "2022-03-25 09:15:25.017664", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", @@ -397,5 +399,6 @@ } ], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 60771592da5..a262b91cb5f 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -32,6 +32,7 @@ class ProductionPlan(Document): self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() self.set_status() + self._rename_temporary_references() def set_pending_qty_in_row_without_reference(self): "Set Pending Qty in independent rows (not from SO or MR)." @@ -57,6 +58,18 @@ class ProductionPlan(Document): if not flt(d.planned_qty): frappe.throw(_("Please enter Planned Qty for Item {0} at row {1}").format(d.item_code, d.idx)) + def _rename_temporary_references(self): + """ po_items and sub_assembly_items items are both constructed client side without saving. + + Attempt to fix linkages by using temporary names to map final row names. + """ + new_name_map = {d.temporary_name: d.name for d in self.po_items if d.temporary_name} + actual_names = {d.name for d in self.po_items} + + for sub_assy in self.sub_assembly_items: + if sub_assy.production_plan_item not in actual_names: + sub_assy.production_plan_item = new_name_map.get(sub_assy.production_plan_item) + @frappe.whitelist() def get_open_sales_orders(self): """ Pull sales orders which are pending to deliver based on criteria selected""" @@ -982,21 +995,21 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): required_qty = item.get("quantity") # get available material by transferring to production warehouse for d in locations: - if required_qty <=0: return + if required_qty <= 0: + return new_dict = copy.deepcopy(item) quantity = required_qty if d.get("qty") > required_qty else d.get("qty") - if required_qty > 0: - new_dict.update({ - "quantity": quantity, - "material_request_type": "Material Transfer", - "uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM - "from_warehouse": d.get("warehouse") - }) + new_dict.update({ + "quantity": quantity, + "material_request_type": "Material Transfer", + "uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM + "from_warehouse": d.get("warehouse") + }) - required_qty -= quantity - new_mr_items.append(new_dict) + required_qty -= quantity + new_mr_items.append(new_dict) # raise purchase request for remaining qty if required_qty: diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 2359815813d..dae16e4bd30 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1,6 +1,7 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_to_date, flt, now_datetime, nowdate from erpnext.controllers.item_variant import create_variant @@ -16,10 +17,9 @@ 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, ) -from erpnext.tests.utils import ERPNextTestCase -class TestProductionPlan(ERPNextTestCase): +class TestProductionPlan(FrappeTestCase): def setUp(self): for item in ['Test Production Item 1', 'Subassembly Item 1', 'Raw Material Item 1', 'Raw Material Item 2']: @@ -605,6 +605,50 @@ class TestProductionPlan(ERPNextTestCase): ] self.assertFalse(pp.all_items_completed()) + def test_production_plan_planned_qty(self): + pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55) + pln.make_work_order() + work_order = frappe.db.get_value('Work Order', {'production_plan': pln.name}, 'name') + wo_doc = frappe.get_doc('Work Order', work_order) + wo_doc.update({ + 'wip_warehouse': 'Work In Progress - _TC', + 'fg_warehouse': 'Finished Goods - _TC' + }) + wo_doc.submit() + self.assertEqual(wo_doc.qty, 0.55) + + def test_temporary_name_relinking(self): + + pp = frappe.new_doc("Production Plan") + + # this can not be unittested so mocking data that would be expected + # from client side. + for _ in range(10): + po_item = pp.append("po_items", { + "name": frappe.generate_hash(length=10), + "temporary_name": frappe.generate_hash(length=10), + }) + pp.append("sub_assembly_items", { + "production_plan_item": po_item.temporary_name + }) + pp._rename_temporary_references() + + for po_item, subassy_item in zip(pp.po_items, pp.sub_assembly_items): + self.assertEqual(po_item.name, subassy_item.production_plan_item) + + # bad links should be erased + pp.append("sub_assembly_items", { + "production_plan_item": frappe.generate_hash(length=16) + }) + pp._rename_temporary_references() + self.assertIsNone(pp.sub_assembly_items[-1].production_plan_item) + pp.sub_assembly_items.pop() + + # reattempting on same doc shouldn't change anything + pp._rename_temporary_references() + for po_item, subassy_item in zip(pp.po_items, pp.sub_assembly_items): + self.assertEqual(po_item.name, subassy_item.production_plan_item) + def create_production_plan(**args): """ diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json index f829d57475a..df5862fcac8 100644 --- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json +++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json @@ -27,7 +27,8 @@ "material_request", "material_request_item", "product_bundle_item", - "item_reference" + "item_reference", + "temporary_name" ], "fields": [ { @@ -204,17 +205,25 @@ "fieldtype": "Data", "hidden": 1, "label": "Item Reference" + }, + { + "fieldname": "temporary_name", + "fieldtype": "Data", + "hidden": 1, + "label": "temporary name" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2021-06-28 18:31:06.822168", + "modified": "2022-03-24 04:54:09.940224", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index 8bd60ea4aca..696d9bca144 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -2,14 +2,14 @@ # See license.txt import frappe from frappe.test_runner import make_test_records +from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.stock.doctype.item.test_item import make_item -from erpnext.tests.utils import ERPNextTestCase -class TestRouting(ERPNextTestCase): +class TestRouting(FrappeTestCase): @classmethod def setUpClass(cls): cls.item_code = "Test Routing Item - A" diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 975216d1bd9..ed7f843271b 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.utils import add_days, add_months, cint, flt, now, today from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError @@ -21,10 +22,9 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_bin -from erpnext.tests.utils import ERPNextTestCase, timeout -class TestWorkOrder(ERPNextTestCase): +class TestWorkOrder(FrappeTestCase): def setUp(self): self.warehouse = '_Test Warehouse 2 - _TC' self.item = '_Test Item' @@ -352,7 +352,6 @@ class TestWorkOrder(ERPNextTestCase): wo_order = make_wo_order_test_record(planned_start_date=now(), sales_order=so.name, qty=3) - wo_order.submit() self.assertEqual(wo_order.docstatus, 1) allow_overproduction("overproduction_percentage_for_sales_order", 0) @@ -937,6 +936,28 @@ class TestWorkOrder(ERPNextTestCase): frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1}) + def test_auto_batch_creation(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + fg_item = frappe.generate_hash(length=20) + child_item = frappe.generate_hash(length=20) + + bom_tree = {fg_item: {child_item: {}}} + + create_nested_bom(bom_tree, prefix="") + + item = frappe.get_doc("Item", fg_item) + item.has_batch_no = 1 + item.create_new_batch = 0 + item.save() + + try: + make_wo_order_test_record(item=fg_item) + except frappe.MandatoryError: + self.fail("Batch generation causing failing in Work Order") + + def update_job_card(job_card, jc_qty=None): employee = frappe.db.get_value('Employee', {'status': 'Active'}, 'name') diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 47fe3296cf1..ee12597d24f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -333,6 +333,14 @@ class WorkOrder(Document): if not self.batch_size: self.batch_size = total_qty + batch_auto_creation = frappe.get_cached_value("Item", self.production_item, "create_new_batch") + if not batch_auto_creation: + frappe.msgprint( + _("Batch not created for item {} since it does not have a batch series.") + .format(frappe.bold(self.production_item)), + alert=True, indicator="orange") + return + while total_qty > 0: qty = self.batch_size if self.batch_size >= total_qty: @@ -449,7 +457,8 @@ class WorkOrder(Document): mr_obj.update_requested_qty([self.material_request_item]) def update_ordered_qty(self): - if self.production_plan and self.production_plan_item: + if self.production_plan and self.production_plan_item \ + and not self.production_plan_sub_assembly_item: qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0 if self.docstatus == 1: @@ -632,15 +641,19 @@ class WorkOrder(Document): if not self.qty > 0: frappe.throw(_("Quantity to Manufacture must be greater than 0.")) - if self.production_plan and self.production_plan_item: + if self.production_plan and self.production_plan_item \ + and not self.production_plan_sub_assembly_item: qty_dict = frappe.db.get_value("Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1) - allowance_qty =flt(frappe.db.get_single_value("Manufacturing Settings", + if not qty_dict: + return + + allowance_qty = flt(frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order"))/100 * qty_dict.get("planned_qty", 0) max_qty = qty_dict.get("planned_qty", 0) + allowance_qty - qty_dict.get("ordered_qty", 0) - if max_qty < 1: + if not max_qty > 0: frappe.throw(_("Cannot produce more item for {0}") .format(self.production_item), OverProductionError) elif self.qty > max_qty: @@ -1138,6 +1151,10 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create doc.insert() frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name)), alert=True) + if enable_capacity_planning: + # automatically added scheduling rows shouldn't change status to WIP + doc.db_set("status", "Open") + return doc def get_work_order_operation_data(work_order, operation, workstation): diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index c298c0a8dbb..dd51017bb75 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -2,6 +2,7 @@ # See license.txt import frappe from frappe.test_runner import make_test_records +from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom @@ -10,13 +11,12 @@ from erpnext.manufacturing.doctype.workstation.workstation import ( WorkstationHolidayError, check_if_within_operating_hours, ) -from erpnext.tests.utils import ERPNextTestCase test_dependencies = ["Warehouse"] test_records = frappe.get_test_records('Workstation') make_test_records('Workstation') -class TestWorkstation(ERPNextTestCase): +class TestWorkstation(FrappeTestCase): def test_validate_timings(self): check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00") check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00") diff --git a/erpnext/non_profit/doctype/donation/donation.json b/erpnext/non_profit/doctype/donation/donation.json index 6759569d54d..d2bcd17a20e 100644 --- a/erpnext/non_profit/doctype/donation/donation.json +++ b/erpnext/non_profit/doctype/donation/donation.json @@ -17,7 +17,8 @@ "paid", "amount", "mode_of_payment", - "razorpay_payment_id", + "column_break_12", + "payment_id", "amended_from" ], "fields": [ @@ -73,12 +74,6 @@ "label": "Mode of Payment", "options": "Mode of Payment" }, - { - "fieldname": "razorpay_payment_id", - "fieldtype": "Data", - "label": "Razorpay Payment ID", - "read_only": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -108,12 +103,21 @@ "options": "Donation", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "payment_id", + "fieldtype": "Data", + "label": "Payment ID" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-03-11 10:53:11.269005", + "modified": "2022-03-16 17:18:45.611741", "modified_by": "Administrator", "module": "Non Profit", "name": "Donation", diff --git a/erpnext/non_profit/doctype/donation/donation.py b/erpnext/non_profit/doctype/donation/donation.py index 617979ef745..85f5a2652e9 100644 --- a/erpnext/non_profit/doctype/donation/donation.py +++ b/erpnext/non_profit/doctype/donation/donation.py @@ -102,7 +102,7 @@ def capture_razorpay_donations(*args, **kwargs): if not donor: donor = create_donor(payment) - donation = create_donation(donor, payment) + donation = create_razorpay_donation(donor, payment) donation.run_method('create_payment_entry') except Exception as e: @@ -114,7 +114,7 @@ def capture_razorpay_donations(*args, **kwargs): return { 'status': 'Success' } -def create_donation(donor, payment): +def create_razorpay_donation(donor, payment): if not frappe.db.exists('Mode of Payment', payment.method): create_mode_of_payment(payment.method) @@ -128,7 +128,7 @@ def create_donation(donor, payment): 'date': getdate(), 'amount': flt(payment.amount) / 100, # Convert to rupees from paise 'mode_of_payment': payment.method, - 'razorpay_payment_id': payment.id + 'payment_id': payment.id }).insert(ignore_mandatory=True) donation.submit() diff --git a/erpnext/non_profit/doctype/donation/test_donation.py b/erpnext/non_profit/doctype/donation/test_donation.py index 5fa731a6aa3..4e39adbcf26 100644 --- a/erpnext/non_profit/doctype/donation/test_donation.py +++ b/erpnext/non_profit/doctype/donation/test_donation.py @@ -5,7 +5,7 @@ import unittest import frappe -from erpnext.non_profit.doctype.donation.donation import create_donation +from erpnext.non_profit.doctype.donation.donation import create_razorpay_donation class TestDonation(unittest.TestCase): @@ -30,7 +30,7 @@ class TestDonation(unittest.TestCase): 'method': 'Debit Card', 'id': 'pay_MeXAmsgeKOhq7O' }) - donation = create_donation(donor, payment) + donation = create_razorpay_donation(donor, payment) self.assertTrue(donation.name) diff --git a/erpnext/non_profit/doctype/member/member.py b/erpnext/non_profit/doctype/member/member.py index 4d80e57eccf..7639c2de68f 100644 --- a/erpnext/non_profit/doctype/member/member.py +++ b/erpnext/non_profit/doctype/member/member.py @@ -100,10 +100,13 @@ def create_customer(user_details, member=None): customer = frappe.new_doc("Customer") customer.customer_name = user_details.fullname customer.customer_type = "Individual" + customer.customer_group = frappe.db.get_single_value("Selling Settings", "customer_group") + customer.territory = frappe.db.get_single_value("Selling Settings", "territory") customer.flags.ignore_mandatory = True customer.insert(ignore_permissions=True) try: + frappe.db.savepoint("contact_creation") contact = frappe.new_doc("Contact") contact.first_name = user_details.fullname if user_details.mobile: @@ -129,6 +132,7 @@ def create_customer(user_details, member=None): return customer.name except Exception as e: + frappe.db.rollback(save_point="contact_creation") frappe.log_error(frappe.get_traceback(), _("Contact Creation Failed")) pass diff --git a/erpnext/non_profit/doctype/membership/membership.json b/erpnext/non_profit/doctype/membership/membership.json index 11d32f9c2b4..df7f723c944 100644 --- a/erpnext/non_profit/doctype/membership/membership.json +++ b/erpnext/non_profit/doctype/membership/membership.json @@ -21,9 +21,11 @@ "paid", "currency", "amount", + "column_break_16", "invoice", "razorpay_details_section", "subscription_id", + "column_break_19", "payment_id" ], "fields": [ @@ -106,20 +108,17 @@ { "fieldname": "razorpay_details_section", "fieldtype": "Section Break", - "hidden": 1, "label": "Razorpay Details" }, { "fieldname": "subscription_id", "fieldtype": "Data", - "label": "Subscription ID", - "read_only": 1 + "label": "Subscription ID" }, { "fieldname": "payment_id", "fieldtype": "Data", - "label": "Payment ID", - "read_only": 1 + "label": "Payment ID" }, { "fieldname": "invoice", @@ -140,11 +139,19 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-02-19 14:33:44.925122", + "modified": "2022-03-16 17:37:28.672916", "modified_by": "Administrator", "module": "Non Profit", "name": "Membership", diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py index 297a2dccb65..2809c8da1a7 100644 --- a/erpnext/non_profit/doctype/membership/membership.py +++ b/erpnext/non_profit/doctype/membership/membership.py @@ -104,7 +104,7 @@ class Membership(Document): return invoice def validate_membership_type_and_settings(self, plan, settings): - settings_link = get_link_to_form("Membership Type", self.membership_type) + settings_link = get_link_to_form("Non Profit Settings", "Non Profit Settings") if not settings.membership_debit_account: frappe.throw(_("You need to set Debit Account in {0}").format(settings_link)) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d53df7e4f63..70f642b7fdb 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -1,6 +1,6 @@ erpnext.patches.v12_0.update_is_cancelled_field -erpnext.patches.v13_0.add_bin_unique_constraint erpnext.patches.v11_0.rename_production_order_to_work_order +erpnext.patches.v13_0.add_bin_unique_constraint erpnext.patches.v11_0.refactor_naming_series erpnext.patches.v11_0.refactor_autoname_naming execute:frappe.reload_doc("accounts", "doctype", "POS Payment Method") #2020-05-28 @@ -352,3 +352,7 @@ erpnext.patches.v13_0.update_reserved_qty_closed_wo erpnext.patches.v13_0.amazon_mws_deprecation_warning erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs +erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022 +erpnext.patches.v13_0.rename_non_profit_fields +erpnext.patches.v13_0.enable_ksa_vat_docs #1 +erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances \ No newline at end of file diff --git a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py index 55125431b52..6f9031fc500 100644 --- a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py +++ b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py @@ -6,14 +6,14 @@ import frappe def execute(): + frappe.reload_doc('hr', 'doctype', 'leave_policy_assignment') + frappe.reload_doc('hr', 'doctype', 'employee_grade') + employee_with_assignment = [] + leave_policy = [] + if "leave_policy" in frappe.db.get_table_columns("Employee"): employees_with_leave_policy = frappe.db.sql("SELECT name, leave_policy FROM `tabEmployee` WHERE leave_policy IS NOT NULL and leave_policy != ''", as_dict = 1) - employee_with_assignment = [] - leave_policy =[] - - #for employee - for employee in employees_with_leave_policy: alloc = frappe.db.exists("Leave Allocation", {"employee":employee.name, "leave_policy": employee.leave_policy, "docstatus": 1}) if not alloc: @@ -22,12 +22,10 @@ def execute(): employee_with_assignment.append(employee.name) leave_policy.append(employee.leave_policy) - - if "default_leave_policy" in frappe.db.get_table_columns("Employee"): + if "default_leave_policy" in frappe.db.get_table_columns("Employee Grade"): employee_grade_with_leave_policy = frappe.db.sql("SELECT name, default_leave_policy FROM `tabEmployee Grade` WHERE default_leave_policy IS NOT NULL and default_leave_policy!=''", as_dict = 1) #for whole employee Grade - for grade in employee_grade_with_leave_policy: employees = get_employee_with_grade(grade.name) for employee in employees: @@ -47,13 +45,13 @@ def execute(): allocation_exists=True) def create_assignment(employee, leave_policy, leave_period=None, allocation_exists = False): + if frappe.db.get_value("Leave Policy", leave_policy, "docstatus") == 2: + return filters = {"employee":employee, "leave_policy": leave_policy} if leave_period: filters["leave_period"] = leave_period - frappe.reload_doc('hr', 'doctype', 'leave_policy_assignment') - if not frappe.db.exists("Leave Policy Assignment" , filters): lpa = frappe.new_doc("Leave Policy Assignment") lpa.employee = employee diff --git a/erpnext/patches/v13_0/enable_ksa_vat_docs.py b/erpnext/patches/v13_0/enable_ksa_vat_docs.py new file mode 100644 index 00000000000..3f482620e16 --- /dev/null +++ b/erpnext/patches/v13_0/enable_ksa_vat_docs.py @@ -0,0 +1,12 @@ +import frappe + +from erpnext.regional.saudi_arabia.setup import add_permissions, add_print_formats + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'Saudi Arabia'}) + if not company: + return + + add_print_formats() + add_permissions() \ No newline at end of file diff --git a/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py b/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py new file mode 100644 index 00000000000..0284097e281 --- /dev/null +++ b/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py @@ -0,0 +1,37 @@ +import frappe + + +def execute(): + """ + Remove "production_plan_item" field where linked field doesn't exist in tha table. + """ + frappe.reload_doc("manufacturing", "doctype", "production_plan_item") + + work_order = frappe.qb.DocType("Work Order") + pp_item = frappe.qb.DocType("Production Plan Item") + + broken_work_orders = ( + frappe.qb + .from_(work_order) + .left_join(pp_item).on(work_order.production_plan_item == pp_item.name) + .select(work_order.name) + .where( + (work_order.docstatus == 0) + & (work_order.production_plan_item.notnull()) + & (work_order.production_plan_item.like("new-production-plan%")) + & (pp_item.name.isnull()) + ) + ).run() + + if not broken_work_orders: + return + + broken_work_order_names = [d[0] for d in broken_work_orders] + + (frappe.qb + .update(work_order) + .set(work_order.production_plan_item, None) + .where(work_order.name.isin(broken_work_order_names)) + ).run() + + diff --git a/erpnext/patches/v13_0/rename_issue_doctype_fields.py b/erpnext/patches/v13_0/rename_issue_doctype_fields.py index bf5438c4d2e..80d51652abe 100644 --- a/erpnext/patches/v13_0/rename_issue_doctype_fields.py +++ b/erpnext/patches/v13_0/rename_issue_doctype_fields.py @@ -60,7 +60,7 @@ def execute(): def convert_to_seconds(value, unit): seconds = 0 - if value == 0: + if not value: return seconds if unit == 'Hours': seconds = value * 3600 diff --git a/erpnext/patches/v13_0/rename_non_profit_fields.py b/erpnext/patches/v13_0/rename_non_profit_fields.py new file mode 100644 index 00000000000..b6fc0a72c10 --- /dev/null +++ b/erpnext/patches/v13_0/rename_non_profit_fields.py @@ -0,0 +1,17 @@ + +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + if frappe.db.table_exists("Donation"): + frappe.reload_doc("non_profit", "doctype", "Donation") + + rename_field("Donation", "razorpay_payment_id", "payment_id") + + if frappe.db.table_exists("Tax Exemption 80G Certificate"): + frappe.reload_doc("regional", "doctype", "Tax Exemption 80G Certificate") + frappe.reload_doc("regional", "doctype", "Tax Exemption 80G Certificate Detail") + + rename_field("Tax Exemption 80G Certificate", "razorpay_payment_id", "payment_id") + rename_field("Tax Exemption 80G Certificate Detail", "razorpay_payment_id", "payment_id") \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py b/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py new file mode 100644 index 00000000000..063de1637d0 --- /dev/null +++ b/erpnext/patches/v13_0/update_expense_claim_status_for_paid_advances.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + """ + Update Expense Claim status to Paid if: + - the entire required amount is already covered via linked advances + - the claim is partially paid via advances and the rest is reimbursed + """ + + ExpenseClaim = frappe.qb.DocType('Expense Claim') + + (frappe.qb + .update(ExpenseClaim) + .set(ExpenseClaim.status, 'Paid') + .where( + ((ExpenseClaim.grand_total == 0) | (ExpenseClaim.grand_total == ExpenseClaim.total_amount_reimbursed)) + & (ExpenseClaim.approval_status == 'Approved') + & (ExpenseClaim.docstatus == 1) + & (ExpenseClaim.total_sanctioned_amount > 0) + ) + ).run() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 4ef29848bc6..579c7b2f504 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -660,6 +660,8 @@ def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progr if not_submitted_ss: frappe.msgprint(_("Could not submit some Salary Slips")) + frappe.flags.via_payroll_entry = False + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filters): @@ -671,7 +673,7 @@ def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filte where reference_type="Payroll Entry") order by name limit %(start)s, %(page_len)s""" .format(key=searchfield), { - 'txt': "%%%s%%" % frappe.db.escape(txt), + 'txt': "%%%s%%" % txt, 'start': start, 'page_len': page_len }) diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 422bb0e1bbc..2691680f578 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -781,11 +781,12 @@ class SalarySlip(TransactionBase): previous_total_paid_taxes = self.get_tax_paid_in_period(payroll_period.start_date, self.start_date, tax_component) # get taxable_earnings for current period (all days) - current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption) + current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption, payroll_period=payroll_period) future_structured_taxable_earnings = current_taxable_earnings.taxable_earnings * (math.ceil(remaining_sub_periods) - 1) # get taxable_earnings, addition_earnings for current actual payment days - current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, based_on_payment_days=1) + current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, + based_on_payment_days=1, payroll_period=payroll_period) current_structured_taxable_earnings = current_taxable_earnings_for_payment_days.taxable_earnings current_additional_earnings = current_taxable_earnings_for_payment_days.additional_income current_additional_earnings_with_full_tax = current_taxable_earnings_for_payment_days.additional_income_with_full_tax @@ -911,7 +912,7 @@ class SalarySlip(TransactionBase): return total_tax_paid - def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0): + def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0, payroll_period=None): joining_date, relieving_date = self.get_joining_and_relieving_dates() taxable_earnings = 0 @@ -938,7 +939,7 @@ class SalarySlip(TransactionBase): # Get additional amount based on future recurring additional salary if additional_amount and earning.is_recurring_additional_salary: additional_income += self.get_future_recurring_additional_amount(earning.additional_salary, - earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month + earning.additional_amount, payroll_period) # Used earning.additional_amount to consider the amount for the full month if earning.deduct_full_tax_on_selected_payroll_date: additional_income_with_full_tax += additional_amount @@ -955,7 +956,7 @@ class SalarySlip(TransactionBase): if additional_amount and ded.is_recurring_additional_salary: additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary, - ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month + ded.additional_amount, payroll_period) # Used ded.additional_amount to consider the amount for the full month return frappe._dict({ "taxable_earnings": taxable_earnings, @@ -964,12 +965,18 @@ class SalarySlip(TransactionBase): "flexi_benefits": flexi_benefits }) - def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount): + def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount, payroll_period): future_recurring_additional_amount = 0 to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date') # future month count excluding current from_date, to_date = getdate(self.start_date), getdate(to_date) + + # If recurring period end date is beyond the payroll period, + # last day of payroll period should be considered for recurring period calculation + if getdate(to_date) > getdate(payroll_period.end_date): + to_date = getdate(payroll_period.end_date) + future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_date.month) if future_recurring_period > 0: @@ -999,7 +1006,7 @@ class SalarySlip(TransactionBase): # apply rounding if frappe.get_cached_value("Salary Component", row.salary_component, "round_to_the_nearest_integer"): - amount, additional_amount = rounded(amount), rounded(additional_amount) + amount, additional_amount = rounded(amount or 0), rounded(additional_amount or 0) return amount, additional_amount @@ -1276,9 +1283,9 @@ class SalarySlip(TransactionBase): def set_base_totals(self): self.base_gross_pay = flt(self.gross_pay) * flt(self.exchange_rate) self.base_total_deduction = flt(self.total_deduction) * flt(self.exchange_rate) - self.rounded_total = rounded(self.net_pay) + self.rounded_total = rounded(self.net_pay or 0) self.base_net_pay = flt(self.net_pay) * flt(self.exchange_rate) - self.base_rounded_total = rounded(self.base_net_pay) + self.base_rounded_total = rounded(self.base_net_pay or 0) self.set_net_total_in_words() #calculate total working hours, earnings based on hourly wages and totals @@ -1390,7 +1397,7 @@ class SalarySlip(TransactionBase): 'total_allocated_leaves': flt(leave_values.get('total_leaves')), 'expired_leaves': flt(leave_values.get('expired_leaves')), 'used_leaves': flt(leave_values.get('leaves_taken')), - 'pending_leaves': flt(leave_values.get('pending_leaves')), + 'pending_leaves': flt(leave_values.get('leaves_pending_approval')), 'available_leaves': flt(leave_values.get('remaining_leaves')) }) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 20060f479ac..c989965ac59 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -38,6 +38,8 @@ from erpnext.payroll.doctype.salary_structure.salary_structure import make_salar class TestSalarySlip(unittest.TestCase): def setUp(self): setup_test() + frappe.flags.pop("via_payroll_entry", None) + def tearDown(self): frappe.db.rollback() @@ -413,15 +415,17 @@ class TestSalarySlip(unittest.TestCase): "email_salary_slip_to_employee": 1 }) def test_email_salary_slip(self): - frappe.db.sql("delete from `tabEmail Queue`") + frappe.db.delete("Email Queue") - make_employee("test_email_salary_slip@salary.com") - ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email") + user_id = "test_email_salary_slip@salary.com" + + make_employee(user_id, company="_Test Company") + ss = make_employee_salary_slip(user_id, "Monthly", "Test Salary Slip Email") ss.company = "_Test Company" ss.save() ss.submit() - email_queue = frappe.db.sql("""select name from `tabEmail Queue`""") + email_queue = frappe.db.a_row_exists("Email Queue") self.assertTrue(email_queue) def test_loan_repayment_salary_slip(self): @@ -1062,7 +1066,7 @@ def create_additional_salary(employee, payroll_period, amount): }).submit() return salary_date -def make_leave_application(employee, from_date, to_date, leave_type, company=None): +def make_leave_application(employee, from_date, to_date, leave_type, company=None, submit=True): leave_application = frappe.get_doc(dict( doctype = 'Leave Application', employee = employee, @@ -1070,11 +1074,12 @@ def make_leave_application(employee, from_date, to_date, leave_type, company=Non from_date = from_date, to_date = to_date, company = company or erpnext.get_default_company() or "_Test Company", - docstatus = 1, status = "Approved", leave_approver = 'test@example.com' - )) - leave_application.submit() + )).insert() + + if submit: + leave_application.submit() return leave_application @@ -1092,20 +1097,22 @@ def setup_test(): frappe.db.set_value('HR Settings', None, 'leave_status_notification_template', None) frappe.db.set_value('HR Settings', None, 'leave_approval_notification_template', None) -def make_holiday_list(): +def make_holiday_list(list_name=None, from_date=None, to_date=None): fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company()) - holiday_list = frappe.db.exists("Holiday List", "Salary Slip Test Holiday List") - if not frappe.db.get_value("Holiday List", "Salary Slip Test Holiday List"): - holiday_list = frappe.get_doc({ - "doctype": "Holiday List", - "holiday_list_name": "Salary Slip Test Holiday List", - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "weekly_off": "Sunday" - }).insert() - holiday_list.get_weekly_off_dates() - holiday_list.save() - holiday_list = holiday_list.name + name = list_name or "Salary Slip Test Holiday List" + + frappe.delete_doc_if_exists("Holiday List", name, force=True) + + holiday_list = frappe.get_doc({ + "doctype": "Holiday List", + "holiday_list_name": name, + "from_date": from_date or fiscal_year[1], + "to_date": to_date or fiscal_year[2], + "weekly_off": "Sunday" + }).insert() + holiday_list.get_weekly_off_dates() + holiday_list.save() + holiday_list = holiday_list.name return holiday_list diff --git a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json index 7ac453b3c3d..60ed4539385 100644 --- a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json +++ b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json @@ -26,7 +26,7 @@ "fieldname": "total_allocated_leaves", "fieldtype": "Float", "in_list_view": 1, - "label": "Total Allocated Leave", + "label": "Total Allocated Leave(s)", "no_copy": 1, "read_only": 1 }, @@ -34,7 +34,7 @@ "fieldname": "expired_leaves", "fieldtype": "Float", "in_list_view": 1, - "label": "Expired Leave", + "label": "Expired Leave(s)", "no_copy": 1, "read_only": 1 }, @@ -42,7 +42,7 @@ "fieldname": "used_leaves", "fieldtype": "Float", "in_list_view": 1, - "label": "Used Leave", + "label": "Used Leave(s)", "no_copy": 1, "read_only": 1 }, @@ -50,7 +50,7 @@ "fieldname": "pending_leaves", "fieldtype": "Float", "in_list_view": 1, - "label": "Pending Leave", + "label": "Leave(s) Pending Approval", "no_copy": 1, "read_only": 1 }, @@ -58,7 +58,7 @@ "fieldname": "available_leaves", "fieldtype": "Float", "in_list_view": 1, - "label": "Available Leave", + "label": "Available Leave(s)", "no_copy": 1, "read_only": 1 } @@ -66,7 +66,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-19 10:47:48.546724", + "modified": "2022-02-28 14:01:32.327204", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Slip Leave", @@ -74,5 +74,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 2b80efd6e33..308f0a4871f 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -34,11 +34,13 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ frappe.model.set_value(item.doctype, item.name, "rate", item_rate); }, - calculate_taxes_and_totals: function(update_paid_amount) { + calculate_taxes_and_totals: async function(update_paid_amount) { this.discount_amount_applied = false; this._calculate_taxes_and_totals(); this.calculate_discount_amount(); + await this.calculate_shipping_charges(); + // Advance calculation applicable to Sales /Purchase Invoice if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype) && this.frm.doc.docstatus < 2 && !this.frm.doc.is_return) { @@ -81,7 +83,6 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ this.initialize_taxes(); this.determine_exclusive_rate(); this.calculate_net_total(); - this.calculate_shipping_charges(); this.calculate_taxes(); this.manipulate_grand_total_for_inclusive_tax(); this.calculate_totals(); @@ -272,27 +273,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ calculate_shipping_charges: function() { frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]); if (frappe.meta.get_docfield(this.frm.doc.doctype, "shipping_rule", this.frm.doc.name)) { - this.shipping_rule(); - } - }, - - add_taxes_from_item_tax_template: function(item_tax_map) { - let me = this; - - if (item_tax_map && cint(frappe.defaults.get_default("add_taxes_from_item_tax_template"))) { - if (typeof (item_tax_map) == "string") { - item_tax_map = JSON.parse(item_tax_map); - } - - $.each(item_tax_map, function(tax, rate) { - let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax); - if (!found) { - let child = frappe.model.add_child(me.frm.doc, "taxes"); - child.charge_type = "On Net Total"; - child.account_head = tax; - child.rate = 0; - } - }); + return this.shipping_rule(); } }, @@ -685,7 +666,12 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ })); this.frm.doc.total_advance = flt(total_allocated_amount, precision("total_advance")); + if (this.frm.doc.write_off_outstanding_amount_automatically) { + this.frm.doc.write_off_amount = 0; + } + this.calculate_outstanding_amount(update_paid_amount); + this.calculate_write_off_amount(); }, is_internal_invoice: function() { @@ -810,7 +796,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ this.frm.set_value('base_paid_amount', flt(base_paid_amount, precision("base_paid_amount"))); }, - calculate_change_amount: function(){ + calculate_change_amount: function() { this.frm.doc.change_amount = 0.0; this.frm.doc.base_change_amount = 0.0; if(in_list(["Sales Invoice", "POS Invoice"], this.frm.doc.doctype) @@ -821,26 +807,23 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; var base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; - this.frm.doc.change_amount = flt(this.frm.doc.paid_amount - grand_total + - this.frm.doc.write_off_amount, precision("change_amount")); + this.frm.doc.change_amount = flt(this.frm.doc.paid_amount - grand_total, + precision("change_amount")); this.frm.doc.base_change_amount = flt(this.frm.doc.base_paid_amount - - base_grand_total + this.frm.doc.base_write_off_amount, - precision("base_change_amount")); + base_grand_total, precision("base_change_amount")); } } }, - calculate_write_off_amount: function(){ - if(this.frm.doc.paid_amount > this.frm.doc.grand_total){ - this.frm.doc.write_off_amount = flt(this.frm.doc.grand_total - this.frm.doc.paid_amount - + this.frm.doc.change_amount, precision("write_off_amount")); - + calculate_write_off_amount: function() { + if (this.frm.doc.write_off_outstanding_amount_automatically) { + this.frm.doc.write_off_amount = flt(this.frm.doc.outstanding_amount, precision("write_off_amount")); this.frm.doc.base_write_off_amount = flt(this.frm.doc.write_off_amount * this.frm.doc.conversion_rate, precision("base_write_off_amount")); - }else{ - this.frm.doc.paid_amount = 0.0; + + this.calculate_outstanding_amount(false); } - this.calculate_outstanding_amount(false); + } }); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index a89776250f2..9ed78a7fb69 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -736,6 +736,26 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ }); }, + add_taxes_from_item_tax_template: function(item_tax_map) { + let me = this; + + if (item_tax_map && cint(frappe.defaults.get_default("add_taxes_from_item_tax_template"))) { + if (typeof (item_tax_map) == "string") { + item_tax_map = JSON.parse(item_tax_map); + } + + $.each(item_tax_map, function(tax, rate) { + let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax); + if (!found) { + let child = frappe.model.add_child(me.frm.doc, "taxes"); + child.charge_type = "On Net Total"; + child.account_head = tax; + child.rate = 0; + } + }); + } + }, + serial_no: function(doc, cdt, cdn) { var me = this; var item = frappe.get_doc(cdt, cdn); @@ -1021,9 +1041,9 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ var me = this; this.set_dynamic_labels(); var company_currency = this.get_company_currency(); - // Added `ignore_pricing_rule` to determine if document is loading after mapping from another doc + // Added `ignore_price_list` to determine if document is loading after mapping from another doc if(this.frm.doc.currency && this.frm.doc.currency !== company_currency - && !this.frm.doc.ignore_pricing_rule) { + && !(this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list)) { this.get_exchange_rate(transaction_date, this.frm.doc.currency, company_currency, function(exchange_rate) { @@ -1049,7 +1069,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } if(flt(this.frm.doc.conversion_rate)>0.0) { - if(this.frm.doc.ignore_pricing_rule) { + if(this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list) { this.calculate_taxes_and_totals(); } else if (!this.in_apply_price_list){ this.apply_price_list(); @@ -1066,6 +1086,9 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ return this.frm.call({ doc: this.frm.doc, method: "apply_shipping_rule", + callback: function(r) { + me._calculate_taxes_and_totals(); + } }).fail(() => this.frm.set_value('shipping_rule', '')); } }, @@ -1123,8 +1146,9 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ this.set_dynamic_labels(); var company_currency = this.get_company_currency(); - // Added `ignore_pricing_rule` to determine if document is loading after mapping from another doc - if(this.frm.doc.price_list_currency !== company_currency && !this.frm.doc.ignore_pricing_rule) { + // Added `ignore_price_list` to determine if document is loading after mapping from another doc + if(this.frm.doc.price_list_currency !== company_currency && + !(this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list)) { this.get_exchange_rate(this.frm.doc.posting_date, this.frm.doc.price_list_currency, company_currency, function(exchange_rate) { me.frm.set_value("plc_conversion_rate", exchange_rate); @@ -1863,6 +1887,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ callback: function(r) { if(!r.exc) { item.item_tax_rate = r.message; + me.add_taxes_from_item_tax_template(item.item_tax_rate); me.calculate_taxes_and_totals(); } } diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 16e3fa0abd1..cbecb11949b 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -75,7 +75,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ fieldtype:'Float', read_only: me.has_batch && !me.has_serial_no, label: __(me.has_batch && !me.has_serial_no ? 'Selected Qty' : 'Qty'), - default: flt(me.item.stock_qty), + default: flt(me.item.stock_qty) || flt(me.item.transfer_qty), }, ...get_pending_qty_fields(me), { @@ -94,14 +94,16 @@ erpnext.SerialNoBatchSelector = Class.extend({ description: __('Fetch Serial Numbers based on FIFO'), click: () => { let qty = this.dialog.fields_dict.qty.get_value(); + let already_selected_serial_nos = get_selected_serial_nos(me); let numbers = frappe.call({ method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number", args: { qty: qty, item_code: me.item_code, warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '', - batch_no: me.item.batch_no || null, - posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date + batch_nos: me.item.batch_no || null, + posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date, + exclude_sr_nos: already_selected_serial_nos } }); @@ -575,21 +577,40 @@ function get_pending_qty_fields(me) { return pending_qty_fields; } -function calc_total_selected_qty(me) { +// get all items with same item code except row for which selector is open. +function get_rows_with_same_item_code(me) { const { frm: { doc: { items }}, item: { name, item_code }} = me; - const totalSelectedQty = items - .filter( item => ( item.name !== name ) && ( item.item_code === item_code ) ) - .map( item => flt(item.qty) ) - .reduce( (i, j) => i + j, 0); + return items.filter(item => (item.name !== name) && (item.item_code === item_code)) +} + +function calc_total_selected_qty(me) { + const totalSelectedQty = get_rows_with_same_item_code(me) + .map(item => flt(item.qty)) + .reduce((i, j) => i + j, 0); return totalSelectedQty; } +function get_selected_serial_nos(me) { + const selected_serial_nos = get_rows_with_same_item_code(me) + .map(item => item.serial_no) + .filter(serial => serial) + .map(sr_no_string => sr_no_string.split('\n')) + .reduce((acc, arr) => acc.concat(arr), []) + .filter(serial => serial); + return selected_serial_nos; +}; + function check_can_calculate_pending_qty(me) { const { frm: { doc }, item } = me; const docChecks = doc.bom_no && doc.fg_completed_qty && erpnext.stock.bom && erpnext.stock.bom.name === doc.bom_no; - const itemChecks = !!item && !item.allow_alternative_item; + const itemChecks = !!item + && !item.allow_alternative_item + && erpnext.stock.bom && erpnext.stock.items + && (item.item_code in erpnext.stock.bom.items); return docChecks && itemChecks; } + +//# sourceURL=serial_no_batch_selector.js diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index b743504a527..843bd86baac 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -4,6 +4,7 @@ --green-info: #38A160; --product-bg-color: white; --body-bg-color: var(--gray-50); + --text-md: 13px; // variables are in desk folder in frappe for v13, this is a temporary fix } body.product-page { @@ -264,6 +265,15 @@ body.product-page { font-size: 13px; } + .filter-lookup-input { + background-color: white; + border: 1px solid var(--gray-300); + + &:focus { + border: 1px solid var(--primary); + } + } + .filter-label { font-size: 11px; font-weight: 600; @@ -569,15 +579,12 @@ body.product-page { } .scroll-categories { - white-space: nowrap; - overflow-x: auto; - .category-pill { - margin: 0px 4px; display: inline-block; - padding: 6px 12px; - background-color: #ecf5fe; width: fit-content; + padding: 6px 12px; + margin-bottom: 8px; + background-color: #ecf5fe; font-size: 14px; border-radius: 18px; color: var(--blue-500); 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 6b31bcc05fc..05b0c3c8f09 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -128,7 +128,8 @@ class GSTR3BReport(Document): def get_inward_nil_exempt(self, state): inward_nil_exempt = frappe.db.sql(""" - SELECT p.place_of_supply, sum(i.base_amount) as base_amount, i.is_nil_exempt, i.is_non_gst + SELECT p.place_of_supply, p.supplier_address, + i.base_amount, i.is_nil_exempt, i.is_non_gst FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i WHERE p.docstatus = 1 and p.name = i.parent and p.is_opening = 'No' @@ -136,7 +137,7 @@ class GSTR3BReport(Document): and (i.is_nil_exempt = 1 or i.is_non_gst = 1 or p.gst_category = 'Registered Composition') and month(p.posting_date) = %s and year(p.posting_date) = %s and p.company = %s and p.company_gstin = %s - GROUP BY p.place_of_supply, i.is_nil_exempt, i.is_non_gst""", + """, (self.month_no, self.year, self.company, self.gst_details.get("gstin")), as_dict=1) inward_nil_exempt_details = { @@ -150,18 +151,24 @@ class GSTR3BReport(Document): } } + address_state_map = get_address_state_map() + for d in inward_nil_exempt: - if d.place_of_supply: - if (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \ - and state == d.place_of_supply.split("-")[1]: - inward_nil_exempt_details["gst"]["intra"] += d.base_amount - elif (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \ - and state != d.place_of_supply.split("-")[1]: - inward_nil_exempt_details["gst"]["inter"] += d.base_amount - elif d.is_non_gst == 1 and state == d.place_of_supply.split("-")[1]: - inward_nil_exempt_details["non_gst"]["intra"] += d.base_amount - elif d.is_non_gst == 1 and state != d.place_of_supply.split("-")[1]: - inward_nil_exempt_details["non_gst"]["inter"] += d.base_amount + if not d.place_of_supply: + d.place_of_supply = "00-" + cstr(state) + + supplier_state = address_state_map.get(d.supplier_address) or state + + if (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \ + and cstr(supplier_state) == cstr(d.place_of_supply.split("-")[1]): + inward_nil_exempt_details["gst"]["intra"] += d.base_amount + elif (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \ + and cstr(supplier_state) != cstr(d.place_of_supply.split("-")[1]): + inward_nil_exempt_details["gst"]["inter"] += d.base_amount + elif d.is_non_gst == 1 and cstr(supplier_state) == cstr(d.place_of_supply.split("-")[1]): + inward_nil_exempt_details["non_gst"]["intra"] += d.base_amount + elif d.is_non_gst == 1 and cstr(supplier_state) != cstr(d.place_of_supply.split("-")[1]): + inward_nil_exempt_details["non_gst"]["inter"] += d.base_amount return inward_nil_exempt_details @@ -420,6 +427,11 @@ class GSTR3BReport(Document): return ",".join(missing_field_invoices) +def get_address_state_map(): + return frappe._dict( + frappe.get_all('Address', fields=['name', 'gst_state'], as_list=1) + ) + def get_json(template): file_path = os.path.join(os.path.dirname(__file__), '{template}.json'.format(template=template)) with open(file_path, 'r') as f: diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js index 54cde9c0cf4..5f840daba67 100644 --- a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js @@ -36,7 +36,7 @@ frappe.ui.form.on('Tax Exemption 80G Certificate', { 'date_of_donation': '', 'amount': 0, 'mode_of_payment': '', - 'razorpay_payment_id': '' + 'payment_id': '' }); } }, diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json index 9eee722f420..9b182ad4969 100644 --- a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json @@ -38,7 +38,7 @@ "amount", "column_break_27", "mode_of_payment", - "razorpay_payment_id" + "payment_id" ], "fields": [ { @@ -201,13 +201,6 @@ "options": "Mode of Payment", "read_only": 1 }, - { - "fetch_from": "donation.razorpay_payment_id", - "fieldname": "razorpay_payment_id", - "fieldtype": "Data", - "label": "RazorPay Payment ID", - "read_only": 1 - }, { "fetch_from": "donation.date", "fieldname": "date_of_donation", @@ -266,11 +259,18 @@ "hidden": 1, "label": "Title", "print_hide": 1 + }, + { + "fetch_from": "donation.payment_id", + "fieldname": "payment_id", + "fieldtype": "Data", + "label": "Payment ID", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-02-22 00:03:34.215633", + "modified": "2022-03-16 17:21:39.831059", "modified_by": "Administrator", "module": "Regional", "name": "Tax Exemption 80G Certificate", diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py index 0f0897841b4..dc3ee6f28e2 100644 --- a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py @@ -6,29 +6,19 @@ import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_company_address from frappe.model.document import Document -from frappe.utils import flt, get_link_to_form, getdate +from frappe.utils import flt, get_link_to_form from erpnext.accounts.utils import get_fiscal_year class TaxExemption80GCertificate(Document): def validate(self): - self.validate_date() self.validate_duplicates() self.validate_company_details() self.set_company_address() self.calculate_total() self.set_title() - def validate_date(self): - if self.recipient == 'Member': - if getdate(self.date): - fiscal_year = get_fiscal_year(fiscal_year=self.fiscal_year, as_dict=True) - - if not (fiscal_year.year_start_date <= getdate(self.date) \ - <= fiscal_year.year_end_date): - frappe.throw(_('The Certificate Date is not in the Fiscal Year {0}').format(frappe.bold(self.fiscal_year))) - def validate_duplicates(self): if self.recipient == 'Donor': certificate = frappe.db.exists(self.doctype, { @@ -96,7 +86,7 @@ class TaxExemption80GCertificate(Document): 'date': doc.from_date, 'amount': doc.amount, 'invoice_id': doc.invoice, - 'razorpay_payment_id': doc.payment_id, + 'payment_id': doc.payment_id, 'membership': doc.name }) total += flt(doc.amount) diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py b/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py index 6fa3b85d061..4e328931ec1 100644 --- a/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py @@ -7,7 +7,7 @@ import frappe from frappe.utils import getdate from erpnext.accounts.utils import get_fiscal_year -from erpnext.non_profit.doctype.donation.donation import create_donation +from erpnext.non_profit.doctype.donation.donation import create_razorpay_donation from erpnext.non_profit.doctype.donation.test_donation import ( create_donor, create_donor_type, @@ -39,11 +39,11 @@ class TestTaxExemption80GCertificate(unittest.TestCase): donor = create_donor() create_mode_of_payment() payment = frappe._dict({ - 'amount': 100, + 'amount': 100, # rzp sends data in paise 'method': 'Debit Card', 'id': 'pay_MeXAmsgeKOhq7O' }) - donation = create_donation(donor, payment) + donation = create_razorpay_donation(donor, payment) args = frappe._dict({ 'recipient': 'Donor', diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json index dfa817dd271..c863aab3285 100644 --- a/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json @@ -9,7 +9,7 @@ "amount", "invoice_id", "column_break_4", - "razorpay_payment_id", + "payment_id", "membership" ], "fields": [ @@ -35,26 +35,28 @@ "options": "Sales Invoice", "reqd": 1 }, - { - "fieldname": "razorpay_payment_id", - "fieldtype": "Data", - "label": "Razorpay Payment ID" - }, { "fieldname": "membership", "fieldtype": "Link", + "in_list_view": 1, "label": "Membership", "options": "Membership" }, { "fieldname": "column_break_4", "fieldtype": "Column Break" + }, + { + "fieldname": "payment_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Payment ID" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-15 16:35:10.777587", + "modified": "2022-03-17 11:55:24.621708", "modified_by": "Administrator", "module": "Regional", "name": "Tax Exemption 80G Certificate Detail", diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 2287714a008..9b2423a6b57 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -21,8 +21,9 @@ PAN_NUMBER_FORMAT = re.compile("[A-Z]{5}[0-9]{4}[A-Z]{1}") def validate_gstin_for_india(doc, method): - if hasattr(doc, 'gst_state') and doc.gst_state: - doc.gst_state_number = state_numbers[doc.gst_state] + if hasattr(doc, 'gst_state'): + set_gst_state_and_state_number(doc) + if not hasattr(doc, 'gstin') or not doc.gstin: return @@ -52,7 +53,6 @@ def validate_gstin_for_india(doc, method): frappe.throw(_("The input you've entered doesn't match the format of GSTIN."), title=_("Invalid GSTIN")) validate_gstin_check_digit(doc.gstin) - set_gst_state_and_state_number(doc) if not doc.gst_state: frappe.throw(_("Please enter GST state"), title=_("Invalid State")) @@ -84,17 +84,14 @@ def update_gst_category(doc, method): frappe.db.set_value(link.link_doctype, {'name': link.link_name, 'gst_category': 'Unregistered'}, 'gst_category', 'Registered Regular') def set_gst_state_and_state_number(doc): - if not doc.gst_state: - if not doc.state: - return + if not doc.gst_state and doc.state: state = doc.state.lower() states_lowercase = {s.lower():s for s in states} if state in states_lowercase: doc.gst_state = states_lowercase[state] else: return - - doc.gst_state_number = state_numbers[doc.gst_state] + doc.gst_state_number = state_numbers.get(doc.gst_state) def validate_gstin_check_digit(gstin, label='GSTIN'): ''' Function to validate the check digit of the GSTIN.''' diff --git a/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json b/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json index a8da0bd2097..2343b4ec5cc 100644 --- a/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json +++ b/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json @@ -10,10 +10,10 @@ "docstatus": 0, "doctype": "Print Format", "font": "Default", - "html": "{% if letter_head and not no_letterhead -%}\n
{{ letter_head }}
\n{%- endif %}\n\n
\n

{{ doc.company }} 80G Donor Certificate

\n
\n

\n\n
\n

{{ _(\"Certificate No. : \") }} {{ doc.name }}

\n

\n \t{{ _(\"Date\") }} : {{ doc.get_formatted(\"date\") }}
\n

\n

\n \n
\n\n This is to confirm that the {{ doc.company }} received an amount of {{doc.get_formatted(\"amount\")}}\n from {{ doc.donor_name }}\n {% if doc.pan_number -%}\n bearing PAN Number {{ doc.member_pan_number }}\n {%- endif %}\n\n via the Mode of Payment {{doc.mode_of_payment}}\n\n {% if doc.razorpay_payment_id -%}\n bearing RazorPay Payment ID {{ doc.razorpay_payment_id }}\n {%- endif %}\n\n on {{ doc.get_formatted(\"date_of_donation\") }}\n

\n \n

\n We thank you for your contribution towards the corpus of the {{ doc.company }} and helping support our work.\n

\n\n
\n
\n\n

\n

{{doc.company_address_display }}

\n\n", + "html": "{% if letter_head and not no_letterhead -%}\n
{{ letter_head }}
\n{%- endif %}\n\n
\n

{{ doc.company }} 80G Donor Certificate

\n
\n

\n\n
\n

{{ _(\"Certificate No. : \") }} {{ doc.name }}

\n

\n \t{{ _(\"Date\") }} : {{ doc.get_formatted(\"date\") }}
\n

\n

\n \n
\n\n This is to confirm that the {{ doc.company }} received an amount of {{doc.get_formatted(\"amount\")}}\n from {{ doc.donor_name }}\n {% if doc.pan_number -%}\n bearing PAN Number {{ doc.member_pan_number }}\n {%- endif %}\n\n via the Mode of Payment {{doc.mode_of_payment}}\n\n {% if doc.payment_id -%}\n bearing Payment ID {{ doc.payment_id }}\n {%- endif %}\n\n on {{ doc.get_formatted(\"date_of_donation\") }}\n

\n \n

\n We thank you for your contribution towards the corpus of the {{ doc.company }} and helping support our work.\n

\n\n
\n
\n\n

\n

{{doc.company_address_display }}

\n\n", "idx": 0, "line_breaks": 0, - "modified": "2021-02-22 00:20:08.516600", + "modified": "2022-03-16 17:25:33.420509", "modified_by": "Administrator", "module": "Regional", "name": "80G Certificate for Donation", diff --git a/erpnext/regional/saudi_arabia/utils.py b/erpnext/regional/saudi_arabia/utils.py index 674ea83cc65..60953ca6d83 100644 --- a/erpnext/regional/saudi_arabia/utils.py +++ b/erpnext/regional/saudi_arabia/utils.py @@ -93,7 +93,7 @@ def create_qr_code(doc, method=None): tlv_array.append(''.join([tag, length, value])) # VAT Amount - vat_amount = str(doc.total_taxes_and_charges) + vat_amount = str(get_vat_amount(doc)) tag = bytes([5]).hex() length = bytes([len(vat_amount)]).hex() @@ -130,6 +130,22 @@ def create_qr_code(doc, method=None): doc.db_set('ksa_einv_qr', _file.file_url) doc.notify_update() +def get_vat_amount(doc): + vat_settings = frappe.db.get_value('KSA VAT Setting', {'company': doc.company}) + vat_accounts = [] + vat_amount = 0 + + if vat_settings: + vat_settings_doc = frappe.get_cached_doc('KSA VAT Setting', vat_settings) + + for row in vat_settings_doc.get('ksa_vat_sales_accounts'): + vat_accounts.append(row.account) + + for tax in doc.get('taxes'): + if tax.account_head in vat_accounts: + vat_amount += tax.tax_amount + + return vat_amount def delete_qr_code_file(doc, method=None): region = get_region(doc.company) diff --git a/erpnext/regional/united_arab_emirates/utils.py b/erpnext/regional/united_arab_emirates/utils.py index 891e75e0033..c18af93b2c8 100644 --- a/erpnext/regional/united_arab_emirates/utils.py +++ b/erpnext/regional/united_arab_emirates/utils.py @@ -28,9 +28,12 @@ def update_itemised_tax_data(doc): elif row.item_code and itemised_tax.get(row.item_code): tax_rate = sum([tax.get('tax_rate', 0) for d, tax in itemised_tax.get(row.item_code).items()]) - row.tax_rate = flt(tax_rate, row.precision("tax_rate")) - row.tax_amount = flt((row.net_amount * tax_rate) / 100, row.precision("net_amount")) - row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount")) + meta = frappe.get_meta(row.doctype) + + if meta.has_field('tax_rate'): + row.tax_rate = flt(tax_rate, row.precision("tax_rate")) + row.tax_amount = flt((row.net_amount * tax_rate) / 100, row.precision("net_amount")) + row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount")) def get_account_currency(account): """Helper function to get account currency.""" diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 7802a3fea44..3da38a34522 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -4,12 +4,13 @@ import frappe from frappe.test_runner import make_test_records +from frappe.tests.utils import FrappeTestCase from frappe.utils import flt from erpnext.accounts.party import get_due_date from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.selling.doctype.customer.customer import get_credit_limit, get_customer_outstanding -from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address +from erpnext.tests.utils import create_test_contact_and_address test_ignore = ["Price List"] test_dependencies = ['Payment Term', 'Payment Terms Template'] @@ -18,7 +19,7 @@ test_records = frappe.get_test_records('Customer') from six import iteritems -class TestCustomer(ERPNextTestCase): +class TestCustomer(FrappeTestCase): def setUp(self): if not frappe.get_value('Item', '_Test Item'): make_test_records('Item') diff --git a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py index b951044f332..9b672b4b5d3 100644 --- a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py +++ b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py @@ -1,12 +1,10 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import unittest - import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.controllers.queries import item_query -from erpnext.tests.utils import ERPNextTestCase test_dependencies = ['Item', 'Customer', 'Supplier'] @@ -18,7 +16,7 @@ def create_party_specific_item(**args): psi.based_on_value = args.get('based_on_value') psi.insert() -class TestPartySpecificItem(ERPNextTestCase): +class TestPartySpecificItem(FrappeTestCase): def setUp(self): self.customer = frappe.get_last_doc("Customer") self.supplier = frappe.get_last_doc("Supplier") diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index f191d9323ee..bf87ba46fc8 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -151,7 +151,6 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): if source.referral_sales_partner: target.sales_partner=source.referral_sales_partner target.commission_rate=frappe.get_value('Sales Partner', source.referral_sales_partner, 'commission_rate') - target.ignore_pricing_rule = 1 target.flags.ignore_permissions = ignore_permissions target.run_method("set_missing_values") target.run_method("calculate_taxes_and_totals") @@ -193,6 +192,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): }, target_doc, set_missing_values, ignore_permissions=ignore_permissions) # postprocess: fetch shipping address, set missing values + doclist.set_onload('ignore_price_list', True) return doclist @@ -226,7 +226,7 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): if customer: target.customer = customer.name target.customer_name = customer.customer_name - target.ignore_pricing_rule = 1 + target.flags.ignore_permissions = ignore_permissions target.run_method("set_missing_values") target.run_method("calculate_taxes_and_totals") @@ -256,6 +256,8 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): } }, target_doc, set_missing_values, ignore_permissions=ignore_permissions) + doclist.set_onload('ignore_price_list', True) + return doclist def _make_customer(source_name, ignore_permissions=False): diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 4357201d23d..a749d9e1f1f 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -2,14 +2,13 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.tests.utils import ERPNextTestCase - test_dependencies = ["Product Bundle"] -class TestQuotation(ERPNextTestCase): +class TestQuotation(FrappeTestCase): def test_make_quotation_without_terms(self): quotation = make_quotation(do_not_save=1) self.assertFalse(quotation.get('payment_schedule')) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 69c85a32533..c15c917f828 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -693,12 +693,12 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( get_ordered_qty: function(item, so) { let ordered_qty = item.ordered_qty; - if (so.packed_items) { + if (so.packed_items && so.packed_items.length) { // calculate ordered qty based on packed items in case of product bundle let packed_items = so.packed_items.filter( (pi) => pi.parent_detail_docname == item.name ); - if (packed_items) { + if (packed_items && packed_items.length) { ordered_qty = packed_items.reduce( (sum, pi) => sum + flt(pi.ordered_qty), 0 diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 7e99a062439..fe2f14e19a6 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -130,6 +130,7 @@ "per_delivered", "column_break_81", "per_billed", + "per_picked", "billing_status", "sales_team_section_break", "sales_partner", @@ -1514,13 +1515,19 @@ "fieldtype": "Currency", "label": "Amount Eligible for Commission", "read_only": 1 + }, + { + "fieldname": "per_picked", + "fieldtype": "Percent", + "label": "% Picked", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-10-05 12:16:40.775704", + "modified": "2022-03-15 21:38:31.437586", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1594,6 +1601,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "customer_name", "track_changes": 1, diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 57c67424f7d..7809a9330ed 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -566,7 +566,6 @@ def make_project(source_name, target_doc=None): @frappe.whitelist() def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") @@ -631,6 +630,8 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values) + target_doc.set_onload('ignore_price_list', True) + return target_doc @frappe.whitelist() @@ -642,7 +643,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): target.set_advances() def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.flags.ignore_permissions = True target.run_method("set_missing_values") target.run_method("set_po_nos") @@ -712,6 +712,8 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): if automatically_fetch_payment_terms: doclist.set_payment_schedule() + doclist.set_onload('ignore_price_list', True) + return doclist @frappe.whitelist() diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 1102fe96fc4..9d093b205e9 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -6,6 +6,7 @@ import json import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.controllers.accounts_controller import update_child_qty_rate @@ -21,10 +22,9 @@ from erpnext.selling.doctype.sales_order.sales_order import ( ) 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 TestSalesOrder(ERPNextTestCase): +class TestSalesOrder(FrappeTestCase): @classmethod def setUpClass(cls): @@ -1405,6 +1405,28 @@ class TestSalesOrder(ERPNextTestCase): self.assertEqual(so.items[0].work_order_qty, wo.produced_qty) self.assertEqual(mr.status, "Manufactured") + def test_sales_order_with_shipping_rule(self): + from erpnext.accounts.doctype.shipping_rule.test_shipping_rule import create_shipping_rule + shipping_rule = create_shipping_rule(shipping_rule_type = "Selling", shipping_rule_name = "Shipping Rule - Sales Invoice Test") + sales_order = make_sales_order(do_not_save=True) + sales_order.shipping_rule = shipping_rule.name + + sales_order.items[0].qty = 1 + sales_order.save() + self.assertEqual(sales_order.taxes[0].tax_amount, 50) + + sales_order.items[0].qty = 2 + sales_order.save() + self.assertEqual(sales_order.taxes[0].tax_amount, 100) + + sales_order.items[0].qty = 3 + sales_order.save() + self.assertEqual(sales_order.taxes[0].tax_amount, 200) + + sales_order.items[0].qty = 21 + sales_order.save() + self.assertEqual(sales_order.taxes[0].tax_amount, 0) + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") accounts_settings.automatically_fetch_payment_terms = enable diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 7e55499533b..195e96486b3 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -23,6 +23,7 @@ "quantity_and_rate", "qty", "stock_uom", + "picked_qty", "col_break2", "uom", "conversion_factor", @@ -798,12 +799,17 @@ "fieldtype": "Check", "label": "Grant Commission", "read_only": 1 + }, + { + "fieldname": "picked_qty", + "fieldtype": "Float", + "label": "Picked Qty" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-02-24 14:41:57.325799", + "modified": "2022-03-15 20:17:33.984799", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py index cad41e1dc03..f7f8a5dbce3 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -1,6 +1,7 @@ import datetime import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice @@ -9,12 +10,11 @@ from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_s execute, ) from erpnext.stock.doctype.item.test_item import create_item -from erpnext.tests.utils import ERPNextTestCase test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"] -class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase): +class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): def create_payment_terms_template(self): # create template for 50-50 payments template = None diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py index d62915fc66d..16162acc8f3 100644 --- a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py +++ b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py @@ -2,6 +2,7 @@ # For license information, please see license.txt +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_months, nowdate from erpnext.selling.doctype.sales_order.sales_order import make_material_request @@ -9,10 +10,9 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import ( execute, ) -from erpnext.tests.utils import ERPNextTestCase -class TestPendingSOItemsForPurchaseRequest(ERPNextTestCase): +class TestPendingSOItemsForPurchaseRequest(FrappeTestCase): def test_result_for_partial_material_request(self): so = make_sales_order() mr=make_material_request(so.name) diff --git a/erpnext/selling/report/sales_analytics/test_analytics.py b/erpnext/selling/report/sales_analytics/test_analytics.py index f56cce2dfdc..564f48fef3b 100644 --- a/erpnext/selling/report/sales_analytics/test_analytics.py +++ b/erpnext/selling/report/sales_analytics/test_analytics.py @@ -3,13 +3,13 @@ import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.report.sales_analytics.sales_analytics import execute -from erpnext.tests.utils import ERPNextTestCase -class TestAnalytics(ERPNextTestCase): +class TestAnalytics(FrappeTestCase): def test_sales_analytics(self): frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'") diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 5bcd0e4e21e..91b2f4f974f 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -133,7 +133,8 @@ def get_child_groups_for_website(item_group_name, immediate=False, include_self= return frappe.get_all( "Item Group", filters=filters, - fields=["name", "route"] + fields=["name", "route"], + order_by="name" ) def get_child_item_groups(item_group_name): diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 0a663c2a188..8d7a2cf8d8c 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -3,15 +3,15 @@ import frappe from frappe.exceptions import ValidationError +from frappe.tests.utils import FrappeTestCase from frappe.utils import cint, flt from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty from erpnext.stock.get_item_details import get_item_details -from erpnext.tests.utils import ERPNextTestCase -class TestBatch(ERPNextTestCase): +class TestBatch(FrappeTestCase): def test_item_has_batch_enabled(self): self.assertRaises(ValidationError, frappe.get_doc({ "doctype": "Batch", diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index 250126c6b98..ec0d8a88e3f 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -2,13 +2,13 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.utils import _create_bin -from erpnext.tests.utils import ERPNextTestCase -class TestBin(ERPNextTestCase): +class TestBin(FrappeTestCase): def test_concurrent_inserts(self): diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 55a4c956a67..7ebc4eed751 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1315,7 +1315,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2021-10-09 14:29:13.428984", + "modified": "2022-03-10 14:29:13.428984", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 00836fc8157..7b1489c40d2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -14,7 +14,6 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.selling_controller import SellingController from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no -from erpnext.stock.utils import calculate_mapped_packed_items_return form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -129,12 +128,8 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() - # Keeps mapped packed_items in case product bundle is updated. - if self.is_return and self.return_against: - calculate_mapped_packed_items_return(self) - else: - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) + from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + make_packing_list(self) if self._action != 'submit' and not self.is_return: set_batch_nos(self, 'warehouse', throw=True) @@ -439,7 +434,6 @@ def make_sales_invoice(source_name, target_doc=None): invoiced_qty_map = get_invoiced_qty_map(source_name) def set_missing_values(source, target): - target.ignore_pricing_rule = 1 target.run_method("set_missing_values") target.run_method("set_po_nos") @@ -525,6 +519,8 @@ def make_sales_invoice(source_name, target_doc=None): if automatically_fetch_payment_terms: doc.set_payment_schedule() + doc.set_onload('ignore_price_list', True) + return doc @frappe.whitelist() @@ -779,3 +775,6 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): }, target_doc, set_missing_values) return doclist + +def on_doctype_update(): + frappe.db.add_index("Delivery Note", ["customer", "is_return", "return_against"]) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index bd18e788ba6..82f4e7dd294 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -6,6 +6,7 @@ import json import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import cstr, flt, nowdate, nowtime from erpnext.accounts.doctype.account.test_account import get_inventory_account @@ -35,10 +36,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ) from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse from erpnext.stock.stock_ledger import get_previous_sle -from erpnext.tests.utils import ERPNextTestCase -class TestDeliveryNote(ERPNextTestCase): +class TestDeliveryNote(FrappeTestCase): def test_over_billing_against_dn(self): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) @@ -386,7 +386,8 @@ class TestDeliveryNote(ERPNextTestCase): self.assertEqual(actual_qty, 25) # return bundled item - dn1 = create_return_delivery_note(source_name=dn.name, rate=500, qty=-2) + dn1 = create_delivery_note(item_code='_Test Product Bundle Item', is_return=1, + return_against=dn.name, qty=-2, rate=500, company=company, warehouse="Stores - TCP1", expense_account="Cost of Goods Sold - TCP1", cost_center="Main - TCP1") # qty after return actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1") @@ -822,15 +823,6 @@ class TestDeliveryNote(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) -def create_return_delivery_note(**args): - args = frappe._dict(args) - from erpnext.controllers.sales_and_purchase_return import make_return_doc - doc = make_return_doc("Delivery Note", args.source_name, None) - doc.items[0].rate = args.rate - doc.items[0].qty = args.qty - doc.submit() - return doc - def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") args = frappe._dict(args) diff --git a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py index 321f48b2c59..dcdff4a0f1e 100644 --- a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py @@ -4,6 +4,7 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, flt, now_datetime, nowdate import erpnext @@ -12,10 +13,10 @@ from erpnext.stock.doctype.delivery_trip.delivery_trip import ( make_expense_claim, notify_customers, ) -from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address +from erpnext.tests.utils import create_test_contact_and_address -class TestDeliveryTrip(ERPNextTestCase): +class TestDeliveryTrip(FrappeTestCase): def setUp(self): super().setUp() driver = create_driver() diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index efcaa90198c..c7835930021 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -108,6 +108,7 @@ class Item(Document): self.validate_variant_attributes() self.validate_variant_based_on_change() self.validate_fixed_asset() + self.clear_retain_sample() self.validate_retain_sample() self.validate_uom_conversion_factor() self.validate_customer_provided_part() @@ -210,6 +211,13 @@ class Item(Document): frappe.throw(_("{0} Retain Sample is based on batch, please check Has Batch No to retain sample of item").format( self.item_code)) + def clear_retain_sample(self): + if not self.has_batch_no: + self.retain_sample = None + + if not self.retain_sample: + self.sample_quantity = None + def add_default_uom_in_conversion_factor_table(self): if not self.is_new() and self.has_value_changed("stock_uom"): self.uoms = [] diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 68e545ba140..9e8bf02a707 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.tests.utils import FrappeTestCase, change_settings from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, @@ -24,12 +25,14 @@ from erpnext.stock.doctype.item.item import ( ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_item_details -from erpnext.tests.utils import ERPNextTestCase, change_settings test_ignore = ["BOM"] test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"] -def make_item(item_code, properties=None): +def make_item(item_code=None, properties=None): + if not item_code: + item_code = frappe.generate_hash(length=16) + if frappe.db.exists("Item", item_code): return frappe.get_doc("Item", item_code) @@ -52,7 +55,7 @@ def make_item(item_code, properties=None): return item -class TestItem(ERPNextTestCase): +class TestItem(FrappeTestCase): def setUp(self): super().setUp() frappe.flags.attribute_values = None @@ -619,6 +622,20 @@ 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", {"sample_retention_warehouse": "_Test Warehouse - _TC"}) + def test_retain_sample(self): + item = make_item("_TestRetainSample", {'has_batch_no': 1, 'retain_sample': 1, 'sample_quantity': 1}) + + self.assertEqual(item.has_batch_no, 1) + self.assertEqual(item.retain_sample, 1) + self.assertEqual(item.sample_quantity, 1) + + item.has_batch_no = None + item.save() + self.assertEqual(item.retain_sample, None) + self.assertEqual(item.sample_quantity, None) + item.delete() + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py index 3976af4e88c..501c1c1ad3c 100644 --- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py @@ -4,6 +4,7 @@ import json import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import flt from erpnext.buying.doctype.purchase_order.purchase_order import ( @@ -18,10 +19,9 @@ from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) -from erpnext.tests.utils import ERPNextTestCase -class TestItemAlternative(ERPNextTestCase): +class TestItemAlternative(FrappeTestCase): def setUp(self): super().setUp() make_items() diff --git a/erpnext/stock/doctype/item_attribute/test_item_attribute.py b/erpnext/stock/doctype/item_attribute/test_item_attribute.py index 0b7ca257151..055c22e0c5d 100644 --- a/erpnext/stock/doctype/item_attribute/test_item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/test_item_attribute.py @@ -6,11 +6,12 @@ import frappe test_records = frappe.get_test_records('Item Attribute') +from frappe.tests.utils import FrappeTestCase + from erpnext.stock.doctype.item_attribute.item_attribute import ItemAttributeIncrementError -from erpnext.tests.utils import ERPNextTestCase -class TestItemAttribute(ERPNextTestCase): +class TestItemAttribute(FrappeTestCase): def setUp(self): super().setUp() if frappe.db.exists("Item Attribute", "_Test_Length"): diff --git a/erpnext/stock/doctype/item_price/test_item_price.py b/erpnext/stock/doctype/item_price/test_item_price.py index f81770e487d..6ceba3f8d3f 100644 --- a/erpnext/stock/doctype/item_price/test_item_price.py +++ b/erpnext/stock/doctype/item_price/test_item_price.py @@ -4,13 +4,13 @@ import frappe from frappe.test_runner import make_test_records_for_doctype +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.item_price.item_price import ItemPriceDuplicateItem from erpnext.stock.get_item_details import get_price_list_rate_for, process_args -from erpnext.tests.utils import ERPNextTestCase -class TestItemPrice(ERPNextTestCase): +class TestItemPrice(FrappeTestCase): def setUp(self): super().setUp() frappe.db.sql("delete from `tabItem Price`") 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 1ea0596d333..6dc4fee5697 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,6 +4,7 @@ import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_to_date, flt, now from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account @@ -15,10 +16,9 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_gl_entries, make_purchase_receipt, ) -from erpnext.tests.utils import ERPNextTestCase -class TestLandedCostVoucher(ERPNextTestCase): +class TestLandedCostVoucher(FrappeTestCase): def test_landed_cost_voucher(self): frappe.db.set_value("Buying Settings", None, "allow_multiple_items", 1) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 50d43171f80..341c83023a3 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -83,6 +83,9 @@ class MaterialRequest(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + def before_update_after_submit(self): + self.validate_schedule_date() + def validate_material_request_type(self): """ Validate fields in accordance with selected type """ diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 705ef27b37a..866f3ab2d57 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -6,6 +6,7 @@ import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import flt, today from erpnext.stock.doctype.item.test_item import create_item @@ -15,10 +16,9 @@ from erpnext.stock.doctype.material_request.material_request import ( make_supplier_quotation, raise_work_orders, ) -from erpnext.tests.utils import ERPNextTestCase -class TestMaterialRequest(ERPNextTestCase): +class TestMaterialRequest(FrappeTestCase): def test_make_purchase_order(self): mr = frappe.copy_doc(test_records[0]).insert() diff --git a/erpnext/stock/doctype/material_request_item/material_request_item.json b/erpnext/stock/doctype/material_request_item/material_request_item.json index 2bad42a0ebb..dd66cfff8be 100644 --- a/erpnext/stock/doctype/material_request_item/material_request_item.json +++ b/erpnext/stock/doctype/material_request_item/material_request_item.json @@ -177,6 +177,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "bold": 1, "columns": 2, "fieldname": "schedule_date", @@ -459,7 +460,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-11-03 14:40:24.409826", + "modified": "2022-03-10 18:42:42.705190", "modified_by": "Administrator", "module": "Stock", "name": "Material Request Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index d6e2e9ce2d7..e94c34d7adc 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -223,6 +223,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Rate", + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -239,7 +240,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-02-22 12:57:45.325488", + "modified": "2022-03-10 15:42:00.265915", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 07c2f1f0dd3..f9c00c59bac 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -185,7 +185,8 @@ def update_packed_item_price_data(pi_row, item_data, doc): row_data.update({ "company": doc.get("company"), "price_list": doc.get("selling_price_list"), - "currency": doc.get("currency") + "currency": doc.get("currency"), + "conversion_rate": doc.get("conversion_rate"), }) rate = get_price_list_rate(row_data, item_doc).get("price_list_rate") diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 2521ac9fe72..5f1b9542d6a 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -1,6 +1,7 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_to_date, nowdate from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle @@ -9,23 +10,32 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde 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 -class TestPackedItem(ERPNextTestCase): +class TestPackedItem(FrappeTestCase): "Test impact on Packed Items table in various scenarios." @classmethod def setUpClass(cls) -> None: super().setUpClass() + cls.warehouse = "_Test Warehouse - _TC" cls.bundle = "_Test Product Bundle X" cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] + + cls.bundle2 = "_Test Product Bundle Y" + cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"] + make_item(cls.bundle, {"is_stock_item": 0}) - for item in cls.bundle_items: + make_item(cls.bundle2, {"is_stock_item": 0}) + for item in cls.bundle_items + cls.bundle2_items: make_item(item, {"is_stock_item": 1}) make_item("_Test Normal Stock Item", {"is_stock_item": 1}) make_product_bundle(cls.bundle, cls.bundle_items, qty=2) + make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2) + + for item in cls.bundle_items + cls.bundle2_items: + make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100) def test_adding_bundle_item(self): "Test impact on packed items if bundle item row is added." @@ -156,3 +166,104 @@ class TestPackedItem(ERPNextTestCase): 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) + + def assertReturns(self, original, returned): + self.assertEqual(len(original), len(returned)) + + sort_function = lambda p: (p.parent_item, p.item_code, p.qty) + + for sent, returned in zip( + sorted(original, key=sort_function), + sorted(returned, key=sort_function) + ): + self.assertEqual(sent.item_code, returned.item_code) + self.assertEqual(sent.parent_item, returned.parent_item) + self.assertEqual(sent.qty, -1 * returned.qty) + + def test_returning_full_bundles(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + item_list = [ + { + "item_code": self.bundle, + "warehouse": self.warehouse, + "qty": 1, + "rate": 100, + }, + { + "item_code": self.bundle2, + "warehouse": self.warehouse, + "qty": 1, + "rate": 100, + } + ] + so = make_sales_order(item_list=item_list, warehouse=self.warehouse) + + dn = make_delivery_note(so.name) + dn.save() + dn.submit() + + # create return + dn_ret = make_sales_return(dn.name) + dn_ret.save() + dn_ret.submit() + self.assertReturns(dn.packed_items, dn_ret.packed_items) + + def test_returning_partial_bundles(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + item_list = [ + { + "item_code": self.bundle, + "warehouse": self.warehouse, + "qty": 1, + "rate": 100, + }, + { + "item_code": self.bundle2, + "warehouse": self.warehouse, + "qty": 1, + "rate": 100, + } + ] + so = make_sales_order(item_list=item_list, warehouse=self.warehouse) + + dn = make_delivery_note(so.name) + dn.save() + dn.submit() + + # create return + dn_ret = make_sales_return(dn.name) + # remove bundle 2 + dn_ret.items.pop() + + dn_ret.save() + dn_ret.submit() + dn_ret.reload() + + self.assertTrue(all(d.parent_item == self.bundle for d in dn_ret.packed_items)) + + expected_returns = [d for d in dn.packed_items if d.parent_item == self.bundle] + self.assertReturns(expected_returns, dn_ret.packed_items) + + + def test_returning_partial_bundle_qty(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so = make_sales_order(item_code=self.bundle, warehouse=self.warehouse, qty = 2) + + dn = make_delivery_note(so.name) + dn.save() + dn.submit() + + # create return + dn_ret = make_sales_return(dn.name) + # halve the qty + dn_ret.items[0].qty = -1 + dn_ret.save() + dn_ret.submit() + + expected_returns = dn.packed_items + for d in expected_returns: + d.qty /= 2 + self.assertReturns(expected_returns, dn_ret.packed_items) diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py index 5eb6b7399ae..bc405b20995 100644 --- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py @@ -4,7 +4,7 @@ import unittest # test_records = frappe.get_test_records('Packing Slip') -from erpnext.tests.utils import ERPNextTestCase +from frappe.tests.utils import FrappeTestCase class TestPackingSlip(unittest.TestCase): diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 730fd7a829c..13b74b5eb16 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -146,10 +146,6 @@ frappe.ui.form.on('Pick List', { customer: frm.doc.customer }; frm.get_items_btn = frm.add_custom_button(__('Get Items'), () => { - if (!frm.doc.customer) { - frappe.msgprint(__('Please select Customer first')); - return; - } erpnext.utils.map_current_doc({ method: 'erpnext.selling.doctype.sales_order.sales_order.create_pick_list', source_doctype: 'Sales Order', diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index b2eaecb5868..35cbc2fd858 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -3,6 +3,8 @@ import json from collections import OrderedDict, defaultdict +from itertools import groupby +from operator import itemgetter import frappe from frappe import _ @@ -24,8 +26,21 @@ class PickList(Document): def before_save(self): self.set_item_locations() + # set percentage picked in SO + for location in self.get('locations'): + if location.sales_order and frappe.db.get_value("Sales Order",location.sales_order,"per_picked") == 100: + frappe.throw("Row " + str(location.idx) + " has been picked already!") + def before_submit(self): for item in self.locations: + # if the user has not entered any picked qty, set it to stock_qty, before submit + if item.picked_qty == 0: + item.picked_qty = item.stock_qty + + if item.sales_order_item: + # update the picked_qty in SO Item + self.update_so(item.sales_order_item,item.picked_qty,item.item_code) + if not frappe.get_cached_value('Item', item.item_code, 'has_serial_no'): continue if not item.serial_no: @@ -37,6 +52,32 @@ class PickList(Document): frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity') .format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch")) + def before_cancel(self): + #update picked_qty in SO Item on cancel of PL + for item in self.get('locations'): + if item.sales_order_item: + self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code) + + def update_so(self,so_item,picked_qty,item_code): + so_doc = frappe.get_doc("Sales Order",frappe.db.get_value("Sales Order Item",so_item,"parent")) + already_picked,actual_qty = frappe.db.get_value("Sales Order Item",so_item,["picked_qty","qty"]) + + if self.docstatus == 1: + if (((already_picked + picked_qty)/ actual_qty)*100) > (100 + flt(frappe.db.get_single_value('Stock Settings', 'over_delivery_receipt_allowance'))): + frappe.throw('You are picking more than required quantity for ' + item_code + '. Check if there is any other pick list created for '+so_doc.name) + + frappe.db.set_value("Sales Order Item",so_item,"picked_qty",already_picked+picked_qty) + + total_picked_qty = 0 + total_so_qty = 0 + for item in so_doc.get('items'): + total_picked_qty += flt(item.picked_qty) + total_so_qty += flt(item.stock_qty) + total_picked_qty=total_picked_qty + picked_qty + per_picked = total_picked_qty/total_so_qty * 100 + + so_doc.db_set("per_picked", flt(per_picked) ,update_modified=False) + @frappe.whitelist() def set_item_locations(self, save=False): self.validate_for_qty() @@ -64,10 +105,6 @@ class PickList(Document): item_doc.name = None for row in locations: - row.update({ - 'picked_qty': row.stock_qty - }) - location = item_doc.as_dict() location.update(row) self.append('locations', location) @@ -340,63 +377,102 @@ def get_available_item_locations_for_other_item(item_code, from_warehouses, requ def create_delivery_note(source_name, target_doc=None): pick_list = frappe.get_doc('Pick List', source_name) validate_item_locations(pick_list) - - sales_orders = [d.sales_order for d in pick_list.locations if d.sales_order] - sales_orders = set(sales_orders) - + sales_dict = dict() + sales_orders = [] delivery_note = None - for sales_order in sales_orders: - delivery_note = create_delivery_note_from_sales_order(sales_order, - delivery_note, skip_item_mapping=True) + for location in pick_list.locations: + if location.sales_order: + sales_orders.append([frappe.db.get_value("Sales Order",location.sales_order,'customer'),location.sales_order]) + # Group sales orders by customer + for key,keydata in groupby(sales_orders,key=itemgetter(0)): + sales_dict[key] = set([d[1] for d in keydata]) - # map rows without sales orders as well - if not delivery_note: + if sales_dict: + delivery_note = create_dn_with_so(sales_dict,pick_list) + + is_item_wo_so = 0 + for location in pick_list.locations : + if not location.sales_order: + is_item_wo_so = 1 + break + if is_item_wo_so == 1: + # Create a DN for items without sales orders as well + delivery_note = create_dn_wo_so(pick_list) + + frappe.msgprint(_('Delivery Note(s) created for the Pick List')) + return delivery_note + +def create_dn_wo_so(pick_list): delivery_note = frappe.new_doc("Delivery Note") - item_table_mapper = { - 'doctype': 'Delivery Note Item', - 'field_map': { - 'rate': 'rate', - 'name': 'so_detail', - 'parent': 'against_sales_order', - }, - 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 - } - - item_table_mapper_without_so = { - 'doctype': 'Delivery Note Item', - 'field_map': { - 'rate': 'rate', - 'name': 'name', - 'parent': '', + item_table_mapper_without_so = { + 'doctype': 'Delivery Note Item', + 'field_map': { + 'rate': 'rate', + 'name': 'name', + 'parent': '', + } } - } + map_pl_locations(pick_list,item_table_mapper_without_so,delivery_note) + delivery_note.insert(ignore_mandatory = True) + + return delivery_note + + +def create_dn_with_so(sales_dict,pick_list): + delivery_note = None + + for customer in sales_dict: + for so in sales_dict[customer]: + delivery_note = None + delivery_note = create_delivery_note_from_sales_order(so, + delivery_note, skip_item_mapping=True) + + item_table_mapper = { + 'doctype': 'Delivery Note Item', + 'field_map': { + 'rate': 'rate', + 'name': 'so_detail', + 'parent': 'against_sales_order', + }, + 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 + } + break + if delivery_note: + # map all items of all sales orders of that customer + for so in sales_dict[customer]: + map_pl_locations(pick_list,item_table_mapper,delivery_note,so) + delivery_note.insert(ignore_mandatory = True) + + return delivery_note + +def map_pl_locations(pick_list,item_mapper,delivery_note,sales_order = None): for location in pick_list.locations: - if location.sales_order_item: - sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item}) - else: - sales_order_item = None + if location.sales_order == sales_order: + if location.sales_order_item: + sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item}) + else: + sales_order_item = None - source_doc, table_mapper = [sales_order_item, item_table_mapper] if sales_order_item \ - else [location, item_table_mapper_without_so] + source_doc, table_mapper = [sales_order_item, item_mapper] if sales_order_item \ + else [location, item_mapper] - dn_item = map_child_doc(source_doc, delivery_note, table_mapper) + dn_item = map_child_doc(source_doc, delivery_note, table_mapper) - if dn_item: - dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) - dn_item.batch_no = location.batch_no - dn_item.serial_no = location.serial_no - - update_delivery_note_item(source_doc, dn_item, delivery_note) + if dn_item: + dn_item.warehouse = location.warehouse + dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) + dn_item.batch_no = location.batch_no + dn_item.serial_no = location.serial_no + update_delivery_note_item(source_doc, dn_item, delivery_note) set_delivery_note_missing_values(delivery_note) delivery_note.pick_list = pick_list.name - delivery_note.customer = pick_list.customer if pick_list.customer else None + delivery_note.company = pick_list.company + delivery_note.customer = frappe.get_value("Sales Order",sales_order,"customer") - return delivery_note @frappe.whitelist() def create_stock_entry(pick_list): diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 41e3150f0d7..f60104c09ac 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -6,17 +6,17 @@ from frappe import _dict test_dependencies = ['Item', 'Sales Invoice', 'Stock Entry', 'Batch'] +from frappe.tests.utils import FrappeTestCase + from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, ) -from erpnext.tests.utils import ERPNextTestCase -class TestPickList(ERPNextTestCase): - +class TestPickList(FrappeTestCase): def test_pick_list_picks_warehouse_for_each_item(self): try: frappe.get_doc({ @@ -187,7 +187,6 @@ class TestPickList(ERPNextTestCase): }] }) pick_list.set_item_locations() - self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no) pr1.cancel() @@ -310,6 +309,7 @@ class TestPickList(ERPNextTestCase): 'item_code': '_Test Item', 'qty': 1, 'conversion_factor': 5, + 'stock_qty':5, 'delivery_date': frappe.utils.today() }, { 'item_code': '_Test Item', @@ -328,9 +328,9 @@ class TestPickList(ERPNextTestCase): 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', - 'qty': 1, - 'stock_qty': 5, - 'conversion_factor': 5, + 'qty': 2, + 'stock_qty': 1, + 'conversion_factor': 0.5, 'sales_order': sales_order.name, 'sales_order_item': sales_order.items[0].name , }, { @@ -388,6 +388,95 @@ class TestPickList(ERPNextTestCase): for expected_item, created_item in zip(expected_items, pl.locations): _compare_dicts(expected_item, created_item) + def test_multiple_dn_creation(self): + sales_order_1 = frappe.get_doc({ + 'doctype': 'Sales Order', + 'customer': '_Test Customer', + 'company': '_Test Company', + 'items': [{ + 'item_code': '_Test Item', + 'qty': 1, + 'conversion_factor': 1, + 'delivery_date': frappe.utils.today() + }], + }).insert() + sales_order_1.submit() + sales_order_2 = frappe.get_doc({ + 'doctype': 'Sales Order', + 'customer': '_Test Customer 1', + 'company': '_Test Company', + 'items': [{ + 'item_code': '_Test Item 2', + 'qty': 1, + 'conversion_factor': 1, + 'delivery_date': frappe.utils.today() + }, + ], + }).insert() + sales_order_2.submit() + pick_list = frappe.get_doc({ + 'doctype': 'Pick List', + 'company': '_Test Company', + 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', + 'picker':'P001', + 'locations': [{ + 'item_code': '_Test Item ', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + 'sales_order': sales_order_1.name, + 'sales_order_item': sales_order_1.items[0].name , + }, { + 'item_code': '_Test Item 2', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + 'sales_order': sales_order_2.name, + 'sales_order_item': sales_order_2.items[0].name , + } + ] + }) + pick_list.set_item_locations() + pick_list.submit() + create_delivery_note(pick_list.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer"},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + self.assertEqual(dn_item.item_code, '_Test Item') + self.assertEqual(dn_item.against_sales_order,sales_order_1.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer 1"},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + self.assertEqual(dn_item.item_code, '_Test Item 2') + self.assertEqual(dn_item.against_sales_order,sales_order_2.name) + #test DN creation without so + pick_list_1 = frappe.get_doc({ + 'doctype': 'Pick List', + 'company': '_Test Company', + 'purpose': 'Delivery', + 'picker':'P001', + 'locations': [{ + 'item_code': '_Test Item ', + 'qty': 1, + 'stock_qty': 1, + 'conversion_factor': 1, + }, { + 'item_code': '_Test Item 2', + 'qty': 2, + 'stock_qty': 2, + 'conversion_factor': 1, + } + ] + }) + pick_list_1.set_item_locations() + pick_list_1.submit() + create_delivery_note(pick_list_1.name) + for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list_1.name},fields={"name"}): + for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"): + if dn_item.item_code == '_Test Item': + self.assertEqual(dn_item.qty,1) + if dn_item.item_code == '_Test Item 2': + self.assertEqual(dn_item.qty,2) + # def test_pick_list_skips_items_in_expired_batch(self): # pass diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index b54a90eed35..6d4b4a19bd2 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -1165,7 +1165,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2022-02-01 11:40:52.690984", + "modified": "2022-03-10 11:40:52.690984", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index afaa8b02a89..2a6b4ea34b4 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -724,7 +724,6 @@ def make_purchase_invoice(source_name, target_doc=None): frappe.throw(_("All items have already been Invoiced/Returned")) doc = frappe.get_doc(target) - doc.ignore_pricing_rule = 1 doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company) doc.run_method("onload") doc.run_method("set_missing_values") @@ -786,6 +785,7 @@ def make_purchase_invoice(source_name, target_doc=None): } }, target_doc, set_missing_values) + doclist.set_onload('ignore_price_list', True) return doclist def get_invoiced_qty_map(purchase_receipt): @@ -888,3 +888,6 @@ def get_item_account_wise_additional_cost(purchase_document): account.base_amount * item.get(based_on_field) / total_item_cost return item_account_wise_cost + +def on_doctype_update(): + frappe.db.add_index("Purchase Receipt", ["supplier", "is_return", "return_against"]) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index a737d873a96..c8a8fce7d63 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -7,6 +7,7 @@ import unittest from collections import defaultdict import frappe +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cint, cstr, flt, today from six import iteritems @@ -18,10 +19,9 @@ 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, change_settings -class TestPurchaseReceipt(ERPNextTestCase): +class TestPurchaseReceipt(FrappeTestCase): def setUp(self): frappe.db.set_value("Buying Settings", None, "allow_multiple_items", 1) diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py index ff1c19a8275..4e8d71fe5e4 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.batch.test_batch import make_new_batch from erpnext.stock.doctype.item.test_item import make_item @@ -9,10 +10,9 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_pu from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.get_item_details import get_conversion_factor -from erpnext.tests.utils import ERPNextTestCase -class TestPutawayRule(ERPNextTestCase): +class TestPutawayRule(FrappeTestCase): def setUp(self): if not frappe.db.exists("Item", "_Rice"): make_item("_Rice", { diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 308c62875d5..601ca054b53 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate from erpnext.controllers.stock_controller import ( @@ -13,12 +14,11 @@ from erpnext.controllers.stock_controller import ( from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.tests.utils import ERPNextTestCase # test_records = frappe.get_test_records('Quality Inspection') -class TestQualityInspection(ERPNextTestCase): +class TestQualityInspection(FrappeTestCase): def setUp(self): super().setUp() create_item("_Test Item with QA") diff --git a/erpnext/stock/doctype/quality_inspection_template/test_records.json b/erpnext/stock/doctype/quality_inspection_template/test_records.json new file mode 100644 index 00000000000..980f49a80aa --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_template/test_records.json @@ -0,0 +1,13 @@ +[ + { + "quality_inspection_template_name" : "_Test Quality Inspection Template", + "doctype": "Quality Inspection Template", + "item_quality_inspection_parameter" : [ + { + "specification": "_Test Param", + "doctype": "Item Quality Inspection Parameter", + "parentfield": "item_quality_inspection_parameter" + } + ] + } +] 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 4b83e5e8532..0dd867f4156 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -45,11 +45,21 @@ class RepostItemValuation(Document): self.db_set('status', self.status) def on_submit(self): - if not frappe.flags.in_test or self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts: + """During tests reposts are executed immediately. + + Exceptions: + 1. "Repost Item Valuation" document has self.flags.dont_run_in_test + 2. global flag frappe.flags.dont_execute_stock_reposts is set + + These flags are useful for asserting real time behaviour like quantity updates. + """ + + if not frappe.flags.in_test: + return + if self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts: return - frappe.enqueue(repost, timeout=1800, queue='long', - job_name='repost_sle', now=frappe.flags.in_test, doc=self) + repost(self) @frappe.whitelist() def restart_reposting(self): diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index e300d46db83..22d84e9cb7d 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -3,13 +3,22 @@ import json +from typing import List, Optional, Union import frappe from frappe import ValidationError, _ from frappe.model.naming import make_autoname -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate -from six import string_types -from six.moves import map +from frappe.query_builder.functions import Coalesce +from frappe.utils import ( + add_days, + cint, + cstr, + flt, + get_link_to_form, + getdate, + nowdate, + safe_json_loads, +) from erpnext.controllers.stock_controller import StockController from erpnext.stock.get_item_details import get_reserved_qty_for_so @@ -413,7 +422,7 @@ def update_serial_nos(sle, item_det): if not sle.is_cancelled and not sle.serial_no and cint(sle.actual_qty) > 0 \ and item_det.has_serial_no == 1 and item_det.serial_no_series: serial_nos = get_auto_serial_nos(item_det.serial_no_series, sle.actual_qty) - frappe.db.set(sle, "serial_no", serial_nos) + sle.db_set("serial_no", serial_nos) validate_serial_no(sle, item_det) if sle.serial_no: auto_make_serial_nos(sle) @@ -535,13 +544,16 @@ def update_serial_nos_after_submit(controller, parentfield): if controller.doctype == "Stock Entry": warehouse = d.t_warehouse qty = d.transfer_qty + elif controller.doctype in ("Sales Invoice", "Delivery Note"): + warehouse = d.warehouse + qty = d.stock_qty else: warehouse = d.warehouse qty = (d.qty if controller.doctype == "Stock Reconciliation" else d.stock_qty) for sle in stock_ledger_entries: if sle.voucher_detail_no==d.name: - if not accepted_serial_nos_updated and qty and abs(sle.actual_qty)==qty \ + if not accepted_serial_nos_updated and qty and abs(sle.actual_qty) == abs(qty) \ and sle.warehouse == warehouse and sle.serial_no != d.serial_no: d.serial_no = sle.serial_no frappe.db.set_value(d.doctype, d.name, "serial_no", sle.serial_no) @@ -580,30 +592,45 @@ def get_delivery_note_serial_no(item_code, qty, delivery_note): return serial_nos @frappe.whitelist() -def auto_fetch_serial_number(qty, item_code, warehouse, posting_date=None, batch_nos=None, for_doctype=None): - filters = { "item_code": item_code, "warehouse": warehouse } +def auto_fetch_serial_number( + qty: float, + item_code: str, + warehouse: str, + posting_date: Optional[str] = None, + batch_nos: Optional[Union[str, List[str]]] = None, + for_doctype: Optional[str] = None, + exclude_sr_nos: Optional[List[str]] = None + ) -> List[str]: + + filters = frappe._dict({"item_code": item_code, "warehouse": warehouse}) + + if exclude_sr_nos is None: + exclude_sr_nos = [] + else: + exclude_sr_nos = safe_json_loads(exclude_sr_nos) + exclude_sr_nos = get_serial_nos(clean_serial_no_string("\n".join(exclude_sr_nos))) if batch_nos: - try: - filters["batch_no"] = json.loads(batch_nos) if (type(json.loads(batch_nos)) == list) else [json.loads(batch_nos)] - except Exception: - filters["batch_no"] = [batch_nos] + batch_nos = safe_json_loads(batch_nos) + if isinstance(batch_nos, list): + filters.batch_no = batch_nos + else: + filters.batch_no = [str(batch_nos)] if posting_date: - filters["expiry_date"] = posting_date + filters.expiry_date = posting_date serial_numbers = [] if for_doctype == 'POS Invoice': - reserved_sr_nos = get_pos_reserved_serial_nos(filters) - serial_numbers = fetch_serial_numbers(filters, qty, do_not_include=reserved_sr_nos) - else: - serial_numbers = fetch_serial_numbers(filters, qty) + exclude_sr_nos.extend(get_pos_reserved_serial_nos(filters)) - return [d.get('name') for d in serial_numbers] + serial_numbers = fetch_serial_numbers(filters, qty, do_not_include=exclude_sr_nos) + + return sorted([d.get('name') for d in serial_numbers]) @frappe.whitelist() def get_pos_reserved_serial_nos(filters): - if isinstance(filters, string_types): + if isinstance(filters, str): filters = json.loads(filters) pos_transacted_sr_nos = frappe.db.sql("""select item.serial_no as serial_no @@ -626,37 +653,37 @@ def get_pos_reserved_serial_nos(filters): def fetch_serial_numbers(filters, qty, do_not_include=None): if do_not_include is None: do_not_include = [] - batch_join_selection = "" - batch_no_condition = "" + batch_nos = filters.get("batch_no") expiry_date = filters.get("expiry_date") + serial_no = frappe.qb.DocType("Serial No") + + query = ( + frappe.qb + .from_(serial_no) + .select(serial_no.name) + .where( + (serial_no.item_code == filters["item_code"]) + & (serial_no.warehouse == filters["warehouse"]) + & (Coalesce(serial_no.sales_invoice, "") == "") + & (Coalesce(serial_no.delivery_document_no, "") == "") + ) + .orderby(serial_no.creation) + .limit(qty or 1) + ) + + if do_not_include: + query = query.where(serial_no.name.notin(do_not_include)) + if batch_nos: - batch_no_condition = """and sr.batch_no in ({}) """.format(', '.join("'%s'" % d for d in batch_nos)) + query = query.where(serial_no.batch_no.isin(batch_nos)) if expiry_date: - batch_join_selection = "LEFT JOIN `tabBatch` batch on sr.batch_no = batch.name " - expiry_date_cond = "AND ifnull(batch.expiry_date, '2500-12-31') >= %(expiry_date)s " - batch_no_condition += expiry_date_cond - - excluded_sr_nos = ", ".join(["" + frappe.db.escape(sr) + "" for sr in do_not_include]) or "''" - serial_numbers = frappe.db.sql(""" - SELECT sr.name FROM `tabSerial No` sr {batch_join_selection} - WHERE - sr.name not in ({excluded_sr_nos}) AND - sr.item_code = %(item_code)s AND - sr.warehouse = %(warehouse)s AND - ifnull(sr.sales_invoice,'') = '' AND - ifnull(sr.delivery_document_no, '') = '' - {batch_no_condition} - ORDER BY - sr.creation - LIMIT - {qty} - """.format( - excluded_sr_nos=excluded_sr_nos, - qty=qty or 1, - batch_join_selection=batch_join_selection, - batch_no_condition=batch_no_condition - ), filters, as_dict=1) + batch = frappe.qb.DocType("Batch") + query = (query + .left_join(batch).on(serial_no.batch_no == batch.name) + .where(Coalesce(batch.expiry_date, "4000-12-31") >= expiry_date) + ) + serial_numbers = query.run(as_dict=True) return serial_numbers diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index f8cea717251..7df0a56b7f3 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -6,10 +6,12 @@ import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.serial_no.serial_no import * from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item @@ -18,11 +20,9 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse test_dependencies = ["Item"] test_records = frappe.get_test_records('Serial No') -from erpnext.stock.doctype.serial_no.serial_no import * -from erpnext.tests.utils import ERPNextTestCase -class TestSerialNo(ERPNextTestCase): +class TestSerialNo(FrappeTestCase): def tearDown(self): frappe.db.rollback() @@ -241,3 +241,57 @@ class TestSerialNo(ERPNextTestCase): ) self.assertEqual(value_diff, -113) + def test_auto_fetch(self): + item_code = make_item(properties={ + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "serial_no_series": "TEST.#######" + }).name + warehouse = "_Test Warehouse - _TC" + + in1 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=5) + in2 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=5) + + in1.reload() + in2.reload() + + batch1 = in1.items[0].batch_no + batch2 = in2.items[0].batch_no + + batch_wise_serials = { + batch1 : get_serial_nos(in1.items[0].serial_no), + batch2: get_serial_nos(in2.items[0].serial_no) + } + + # Test FIFO + first_fetch = auto_fetch_serial_number(5, item_code, warehouse) + self.assertEqual(first_fetch, batch_wise_serials[batch1]) + + # partial FIFO + partial_fetch = auto_fetch_serial_number(2, item_code, warehouse) + self.assertTrue(set(partial_fetch).issubset(set(first_fetch)), + msg=f"{partial_fetch} should be subset of {first_fetch}") + + # exclusion + remaining = auto_fetch_serial_number(3, item_code, warehouse, + exclude_sr_nos=json.dumps(partial_fetch)) + self.assertEqual(sorted(remaining + partial_fetch), first_fetch) + + # batchwise + for batch, expected_serials in batch_wise_serials.items(): + fetched_sr = auto_fetch_serial_number(5, item_code, warehouse, batch_nos=batch) + self.assertEqual(fetched_sr, sorted(expected_serials)) + + # non existing warehouse + self.assertEqual(auto_fetch_serial_number(10, item_code, warehouse="Nonexisting"), []) + + # multi batch + all_serials = [sr for sr_list in batch_wise_serials.values() for sr in sr_list] + fetched_serials = auto_fetch_serial_number(10, item_code, warehouse, batch_nos=list(batch_wise_serials.keys())) + self.assertEqual(sorted(all_serials), fetched_serials) + + # expiry date + frappe.db.set_value("Batch", batch1, "expiry_date", "1980-01-01") + non_expired_serials = auto_fetch_serial_number(5, item_code, warehouse, posting_date="2021-01-01", batch_nos=batch1) + self.assertEqual(non_expired_serials, []) diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index afe821845ae..317abb6d03e 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -4,12 +4,12 @@ from datetime import date, timedelta import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.delivery_note.delivery_note import make_shipment -from erpnext.tests.utils import ERPNextTestCase -class TestShipment(ERPNextTestCase): +class TestShipment(FrappeTestCase): def test_shipment_from_delivery_note(self): delivery_note = create_test_delivery_note() delivery_note.submit() diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 3c34d4795cb..6a3b21d81c8 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -6,6 +6,7 @@ import unittest import frappe from frappe.permissions import add_user_permission, remove_user_permission +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import flt, nowdate, nowtime from six import iteritems @@ -29,7 +30,6 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, ) from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle -from erpnext.tests.utils import ERPNextTestCase, change_settings def get_sle(**args): @@ -43,7 +43,7 @@ def get_sle(**args): order by timestamp(posting_date, posting_time) desc, creation desc limit 1"""% condition, values, as_dict=1) -class TestStockEntry(ERPNextTestCase): +class TestStockEntry(FrappeTestCase): def tearDown(self): frappe.db.rollback() frappe.set_user("Administrator") diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 39ca97a47bc..29a0d078692 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -27,6 +27,8 @@ class StockLedgerEntry(Document): name will be changed using autoname options (in a scheduled job) """ self.name = frappe.generate_hash(txt="", length=10) + if self.meta.autoname == "hash": + self.to_rename = 0 def validate(self): self.flags.ignore_submit_comment = True diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 6d113ba4eb6..1c3e0bfc229 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -5,12 +5,12 @@ import json import frappe from frappe.core.page.permission_manager.permission_manager import reset +from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today -from erpnext.stock.doctype.delivery_note.test_delivery_note import ( - create_delivery_note, - create_return_delivery_note, -) +from erpnext.accounts.doctype.gl_entry.gl_entry import rename_gle_sle_docs +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( create_landed_cost_voucher, @@ -22,10 +22,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, ) from erpnext.stock.stock_ledger import get_previous_sle -from erpnext.tests.utils import ERPNextTestCase -class TestStockLedgerEntry(ERPNextTestCase): +class TestStockLedgerEntry(FrappeTestCase): def setUp(self): items = create_items() reset('Stock Entry') @@ -258,7 +257,8 @@ class TestStockLedgerEntry(ERPNextTestCase): self.assertEqual(outgoing_rate, 100) # Return Entry: Qty = -2, Rate = 150 - return_dn = create_return_delivery_note(source_name=dn.name, rate=150, qty=-2) + return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150, + company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") # check incoming rate for Return entry incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", @@ -443,6 +443,31 @@ class TestStockLedgerEntry(ERPNextTestCase): {"incoming_rate": sum(rates) * 10} ], sle_filters={"item_code": packed.name}) + @change_settings("Stock Settings", {"allow_negative_stock": 1}) + def test_negative_fifo_valuation(self): + """ + When stock goes negative discard FIFO queue. + Only pervailing valuation rate should be used for making transactions in such cases. + """ + item = make_item(properties={"allow_negative_stock": 1}).name + warehouse = "_Test Warehouse - _TC" + + receipt = make_stock_entry(item_code=item, target=warehouse, qty=10, rate=10) + consume1 = make_stock_entry(item_code=item, source=warehouse, qty=15) + + self.assertSLEs(consume1, [ + {"stock_value": -5 * 10, "stock_queue": [[-5, 10]]} + ]) + + consume2 = make_stock_entry(item_code=item, source=warehouse, qty=5) + self.assertSLEs(consume2, [ + {"stock_value": -10 * 10, "stock_queue": [[-10, 10]]} + ]) + + receipt2 = make_stock_entry(item_code=item, target=warehouse, qty=15, rate=15) + self.assertSLEs(receipt2, [ + {"stock_queue": [[5, 15]], "stock_value_difference": 175} + ]) def create_repack_entry(**args): args = frappe._dict(args) @@ -506,3 +531,62 @@ def create_items(): make_item(d, properties=properties) return items + + +class TestDeferredNaming(FrappeTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.gle_autoname = frappe.get_meta("GL Entry").autoname + cls.sle_autoname = frappe.get_meta("Stock Ledger Entry").autoname + + def setUp(self) -> None: + self.item = make_item().name + self.warehouse = "Stores - TCP1" + self.company = "_Test Company with perpetual inventory" + + def tearDown(self) -> None: + make_property_setter(doctype="GL Entry", for_doctype=True, + property="autoname", value=self.gle_autoname, property_type="Data", fieldname=None) + make_property_setter(doctype="Stock Ledger Entry", for_doctype=True, + property="autoname", value=self.sle_autoname, property_type="Data", fieldname=None) + + # since deferred naming autocommits, commit all changes to avoid flake + frappe.db.commit() # nosemgrep + + @staticmethod + def get_gle_sles(se): + filters = {"voucher_type": se.doctype, "voucher_no": se.name} + gle = set(frappe.get_list("GL Entry", filters, pluck="name")) + sle = set(frappe.get_list("Stock Ledger Entry", filters, pluck="name")) + return gle, sle + + def test_deferred_naming(self): + se = make_stock_entry(item_code=self.item, to_warehouse=self.warehouse, + qty=10, rate=100, company=self.company) + + gle, sle = self.get_gle_sles(se) + rename_gle_sle_docs() + renamed_gle, renamed_sle = self.get_gle_sles(se) + + self.assertFalse(gle & renamed_gle, msg="GLEs not renamed") + self.assertFalse(sle & renamed_sle, msg="SLEs not renamed") + se.cancel() + + def test_hash_naming(self): + # disable naming series + for doctype in ("GL Entry", "Stock Ledger Entry"): + make_property_setter(doctype=doctype, for_doctype=True, + property="autoname", value="hash", property_type="Data", fieldname=None) + + se = make_stock_entry(item_code=self.item, to_warehouse=self.warehouse, + qty=10, rate=100, company=self.company) + + gle, sle = self.get_gle_sles(se) + rename_gle_sle_docs() + renamed_gle, renamed_sle = self.get_gle_sles(se) + + self.assertEqual(gle, renamed_gle, msg="GLEs are renamed while using hash naming") + self.assertEqual(sle, renamed_sle, msg="SLEs are renamed while using hash naming") + se.cancel() diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 86af0a0cf3b..d3e63713847 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -6,6 +6,7 @@ import frappe +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string from erpnext.accounts.utils import get_stock_and_account_balance @@ -19,10 +20,9 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method -from erpnext.tests.utils import ERPNextTestCase, change_settings -class TestStockReconciliation(ERPNextTestCase): +class TestStockReconciliation(FrappeTestCase): @classmethod def setUpClass(cls): create_batch_or_serial_no_items() diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 6167becdaac..66da215dbbe 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -13,6 +13,25 @@ frappe.ui.form.on('Stock Settings', { frm.set_query("default_warehouse", filters); frm.set_query("sample_retention_warehouse", filters); + }, + allow_negative_stock: function(frm) { + if (!frm.doc.allow_negative_stock) { + return; + } + + let msg = __("Using negative stock disables FIFO/Moving average valuation when inventory is negative."); + msg += " "; + msg += __("This is considered dangerous from accounting point of view.") + msg += "
"; + msg += ("Do you still want to enable negative inventory?"); + + frappe.confirm( + msg, + () => {}, + () => { + frm.set_value("allow_negative_stock", 0); + } + ); } }); diff --git a/erpnext/stock/doctype/stock_settings/test_stock_settings.py b/erpnext/stock/doctype/stock_settings/test_stock_settings.py index 072b54b8205..13496718ead 100644 --- a/erpnext/stock/doctype/stock_settings/test_stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/test_stock_settings.py @@ -4,11 +4,10 @@ import unittest import frappe - -from erpnext.tests.utils import ERPNextTestCase +from frappe.tests.utils import FrappeTestCase -class TestStockSettings(ERPNextTestCase): +class TestStockSettings(FrappeTestCase): def setUp(self): super().setUp() frappe.db.set_value("Stock Settings", None, "clean_description_html", 0) diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 26db2642e4b..08d7c993521 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -3,17 +3,17 @@ import frappe from frappe.test_runner import make_test_records -from frappe.utils import cint +from frappe.tests.utils import FrappeTestCase import erpnext -from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.tests.utils import ERPNextTestCase +from erpnext.stock.doctype.warehouse.warehouse import convert_to_group_or_ledger, get_children test_records = frappe.get_test_records('Warehouse') -class TestWarehouse(ERPNextTestCase): +class TestWarehouse(FrappeTestCase): def setUp(self): super().setUp() if not frappe.get_value('Item', '_Test Item'): @@ -65,6 +65,33 @@ class TestWarehouse(ERPNextTestCase): f"{item} linked to {item_default.default_warehouse} in {warehouse_ids}." ) + def test_group_non_group_conversion(self): + + warehouse = frappe.get_doc("Warehouse", create_warehouse("TestGroupConversion")) + + convert_to_group_or_ledger(warehouse.name) + warehouse.reload() + self.assertEqual(warehouse.is_group, 1) + + child = create_warehouse("GroupWHChild", {"parent_warehouse": warehouse.name}) + # chid exists + self.assertRaises(frappe.ValidationError, convert_to_group_or_ledger, warehouse.name) + frappe.delete_doc("Warehouse", child) + + convert_to_group_or_ledger(warehouse.name) + warehouse.reload() + self.assertEqual(warehouse.is_group, 0) + + make_stock_entry(item_code="_Test Item", target=warehouse.name, qty=1) + # SLE exists + self.assertRaises(frappe.ValidationError, convert_to_group_or_ledger, warehouse.name) + + def test_get_children(self): + company = "_Test Company" + + children = get_children("Warehouse", parent=company, company=company, is_root=True) + self.assertTrue(any(wh['value'] == "_Test Warehouse - _TC" for wh in children)) + def create_warehouse(warehouse_name, properties=None, company=None): if not company: diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 9cfad86f142..4c7f41dcb5e 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -41,14 +41,11 @@ class Warehouse(NestedSet): def on_trash(self): # delete bin - bins = frappe.db.sql("select * from `tabBin` where warehouse = %s", - self.name, as_dict=1) + bins = frappe.get_all("Bin", fields="*", filters={"warehouse": self.name}) for d in bins: if d['actual_qty'] or d['reserved_qty'] or d['ordered_qty'] or \ d['indented_qty'] or d['projected_qty'] or d['planned_qty']: throw(_("Warehouse {0} can not be deleted as quantity exists for Item {1}").format(self.name, d['item_code'])) - else: - frappe.db.sql("delete from `tabBin` where name = %s", d['name']) if self.check_if_sle_exists(): throw(_("Warehouse can not be deleted as stock ledger entry exists for this warehouse.")) @@ -56,16 +53,15 @@ class Warehouse(NestedSet): if self.check_if_child_exists(): throw(_("Child warehouse exists for this warehouse. You can not delete this warehouse.")) + frappe.db.delete("Bin", filters={"warehouse": self.name}) self.update_nsm_model() self.unlink_from_items() def check_if_sle_exists(self): - return frappe.db.sql("""select name from `tabStock Ledger Entry` - where warehouse = %s limit 1""", self.name) + return frappe.db.exists("Stock Ledger Entry", {"warehouse": self.name}) def check_if_child_exists(self): - return frappe.db.sql("""select name from `tabWarehouse` - where parent_warehouse = %s limit 1""", self.name) + return frappe.db.exists("Warehouse", {"parent_warehouse": self.name}) def convert_to_group_or_ledger(self): if self.is_group: @@ -92,10 +88,7 @@ class Warehouse(NestedSet): return 1 def unlink_from_items(self): - frappe.db.sql(""" - update `tabItem Default` - set default_warehouse=NULL - where default_warehouse=%s""", self.name) + frappe.db.set_value("Item Default", {"default_warehouse": self.name}, "default_warehouse", None) @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False): @@ -164,15 +157,16 @@ def add_node(): frappe.get_doc(args).insert() @frappe.whitelist() -def convert_to_group_or_ledger(): - args = frappe.form_dict - return frappe.get_doc("Warehouse", args.docname).convert_to_group_or_ledger() +def convert_to_group_or_ledger(docname=None): + if not docname: + docname = frappe.form_dict.docname + return frappe.get_doc("Warehouse", docname).convert_to_group_or_ledger() def get_child_warehouses(warehouse): - lft, rgt = frappe.get_cached_value("Warehouse", warehouse, ["lft", "rgt"]) + from frappe.utils.nestedset import get_descendants_of - return frappe.db.sql_list("""select name from `tabWarehouse` - where lft >= %s and rgt <= %s""", (lft, rgt)) + children = get_descendants_of("Warehouse", warehouse, ignore_permissions=True, order_by="lft") + return children + [warehouse] # append self for backward compatibility def get_warehouses_based_on_account(account, company=None): warehouses = [] diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 97a740e1844..7ca40033edb 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -12,7 +12,7 @@ from frappe.utils import cint, date_diff, flt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos Filters = frappe._dict -precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + def execute(filters: Filters = None) -> Tuple: to_date = filters["to_date"] @@ -30,6 +30,8 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li _func = itemgetter(1) data = [] + precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + for item, item_dict in item_details.items(): earliest_age, latest_age = 0, 0 details = item_dict["details"] @@ -76,6 +78,9 @@ def get_average_age(fifo_queue: List, to_date: str) -> float: return flt(age_qty / total_qty, 2) if total_qty else 0.0 def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple: + + precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + range1 = range2 = range3 = above_range3 = 0.0 for item in fifo_queue: diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 562d178c329..ca963b74863 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -2,12 +2,12 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data -from erpnext.tests.utils import ERPNextTestCase -class TestStockAgeing(ERPNextTestCase): +class TestStockAgeing(FrappeTestCase): def setUp(self) -> None: self.filters = frappe._dict( company="_Test Company", @@ -610,4 +610,4 @@ def generate_item_and_item_wh_wise_slots(filters, sle): 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 + return item_wise_slots, item_wh_wise_slots diff --git a/erpnext/stock/report/stock_analytics/test_stock_analytics.py b/erpnext/stock/report/stock_analytics/test_stock_analytics.py index 32df5859375..f6c98f914d2 100644 --- a/erpnext/stock/report/stock_analytics/test_stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/test_stock_analytics.py @@ -1,14 +1,13 @@ import datetime -import unittest from frappe import _dict +from frappe.tests.utils import FrappeTestCase from erpnext.accounts.utils import get_fiscal_year from erpnext.stock.report.stock_analytics.stock_analytics import get_period_date_ranges -from erpnext.tests.utils import ERPNextTestCase -class TestStockAnalyticsReport(ERPNextTestCase): +class TestStockAnalyticsReport(FrappeTestCase): def test_get_period_date_ranges(self): filters = _dict(range="Monthly", from_date="2020-12-28", to_date="2021-02-06") diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index b8bdf39301e..b843d52b1bf 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -422,19 +422,6 @@ def is_reposting_item_valuation_in_progress(): if reposting_in_progress: frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) - -def calculate_mapped_packed_items_return(return_doc): - parent_items = set([item.parent_item for item in return_doc.packed_items]) - against_doc = frappe.get_doc(return_doc.doctype, return_doc.return_against) - - for original_bundle, returned_bundle in zip(against_doc.items, return_doc.items): - if original_bundle.item_code in parent_items: - for returned_packed_item, original_packed_item in zip(return_doc.packed_items, against_doc.packed_items): - if returned_packed_item.parent_item == original_bundle.item_code: - returned_packed_item.parent_detail_docname = returned_bundle.name - returned_packed_item.qty = (original_packed_item.qty / original_bundle.qty) * returned_bundle.qty - - def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool: """Check if there are pending reposting job till the specified posting date.""" diff --git a/erpnext/templates/generators/item_group.html b/erpnext/templates/generators/item_group.html index 218481581c5..3926f77b1fa 100644 --- a/erpnext/templates/generators/item_group.html +++ b/erpnext/templates/generators/item_group.html @@ -52,24 +52,6 @@ - diff --git a/erpnext/templates/includes/macros.html b/erpnext/templates/includes/macros.html index 892d62513e2..fb4cecf8266 100644 --- a/erpnext/templates/includes/macros.html +++ b/erpnext/templates/includes/macros.html @@ -300,13 +300,13 @@ {% if values | len > 20 %} - + {% endif %} {% if values %}
{% for value in values %} -
+