diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index 9e2cdfffd9..ab1061beeb 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -120,6 +120,7 @@ def get_booking_dates(doc, item, posting_date=None): prev_gl_entry = frappe.db.sql(''' select name, posting_date from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s + and is_cancelled = 0 order by posting_date desc limit 1 ''', (doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name), as_dict=True) @@ -227,6 +228,7 @@ def get_already_booked_amount(doc, item): gl_entries_details = frappe.db.sql(''' select sum({0}) as total_credit, sum({1}) as total_credit_in_account_currency, voucher_detail_no from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s + and is_cancelled = 0 group by voucher_detail_no '''.format(total_credit_debit, total_credit_debit_currency), (doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name), as_dict=True) @@ -282,7 +284,7 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): return # check if books nor frozen till endate: - if getdate(end_date) >= getdate(accounts_frozen_upto): + if accounts_frozen_upto and (end_date) <= getdate(accounts_frozen_upto): end_date = get_last_day(add_days(accounts_frozen_upto, 1)) if via_journal_entry: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index a9bc0280bc..b2b818a214 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -194,8 +194,14 @@ frappe.ui.form.on('Payment Entry', { frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency)); frm.toggle_display("base_paid_amount", frm.doc.paid_from_account_currency != company_currency); - frm.toggle_display("base_total_taxes_and_charges", frm.doc.total_taxes_and_charges && - (frm.doc.paid_from_account_currency != company_currency)); + + if (frm.doc.payment_type == "Pay") { + frm.toggle_display("base_total_taxes_and_charges", frm.doc.total_taxes_and_charges && + (frm.doc.paid_to_account_currency != company_currency)); + } else { + frm.toggle_display("base_total_taxes_and_charges", frm.doc.total_taxes_and_charges && + (frm.doc.paid_from_account_currency != company_currency)); + } frm.toggle_display("base_received_amount", ( frm.doc.paid_to_account_currency != company_currency @@ -230,7 +236,8 @@ frappe.ui.form.on('Payment Entry', { var company_currency = frm.doc.company? frappe.get_doc(":Company", frm.doc.company).default_currency: ""; frm.set_currency_labels(["base_paid_amount", "base_received_amount", "base_total_allocated_amount", - "difference_amount", "base_paid_amount_after_tax", "base_received_amount_after_tax"], company_currency); + "difference_amount", "base_paid_amount_after_tax", "base_received_amount_after_tax", + "base_total_taxes_and_charges"], company_currency); frm.set_currency_labels(["paid_amount"], frm.doc.paid_from_account_currency); frm.set_currency_labels(["received_amount"], frm.doc.paid_to_account_currency); @@ -339,6 +346,8 @@ frappe.ui.form.on('Payment Entry', { } frm.set_party_account_based_on_party = true; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + return frappe.call({ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details", args: { @@ -372,7 +381,11 @@ frappe.ui.form.on('Payment Entry', { if (r.message.bank_account) { frm.set_value("bank_account", r.message.bank_account); } - } + }, + () => frm.events.set_current_exchange_rate(frm, "source_exchange_rate", + frm.doc.paid_from_account_currency, company_currency), + () => frm.events.set_current_exchange_rate(frm, "target_exchange_rate", + frm.doc.paid_to_account_currency, company_currency) ]); } } @@ -476,14 +489,14 @@ frappe.ui.form.on('Payment Entry', { }, paid_from_account_currency: function(frm) { - if(!frm.doc.paid_from_account_currency) return; - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + if(!frm.doc.paid_from_account_currency || !frm.doc.company) return; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.paid_from_account_currency == company_currency) { frm.set_value("source_exchange_rate", 1); } else if (frm.doc.paid_from){ if (in_list(["Internal Transfer", "Pay"], frm.doc.payment_type)) { - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; frappe.call({ method: "erpnext.setup.utils.get_exchange_rate", args: { @@ -503,8 +516,8 @@ frappe.ui.form.on('Payment Entry', { }, paid_to_account_currency: function(frm) { - if(!frm.doc.paid_to_account_currency) return; - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + if(!frm.doc.paid_to_account_currency || !frm.doc.company) return; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; frm.events.set_current_exchange_rate(frm, "target_exchange_rate", frm.doc.paid_to_account_currency, company_currency); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index c8d1db91f5..3fc1adff2d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -66,7 +66,9 @@ "tax_withholding_category", "section_break_56", "taxes", + "section_break_60", "base_total_taxes_and_charges", + "column_break_61", "total_taxes_and_charges", "deductions_or_loss_section", "deductions", @@ -715,12 +717,21 @@ "fieldtype": "Data", "hidden": 1, "label": "Paid To Account Type" + }, + { + "fieldname": "column_break_61", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_60", + "fieldtype": "Section Break", + "hide_border": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-11-24 18:58:24.919764", + "modified": "2022-02-23 20:08:39.559814", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", @@ -763,6 +774,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index c36c843889..f9f33502d3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -934,8 +934,12 @@ class PaymentEntry(AccountsController): tax.base_total = tax.total * self.source_exchange_rate - self.total_taxes_and_charges += current_tax_amount - self.base_total_taxes_and_charges += current_tax_amount * self.source_exchange_rate + if self.payment_type == 'Pay': + self.base_total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate) + self.total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate) + else: + self.base_total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate) + self.total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate) if self.get('taxes'): self.paid_amount_after_tax = self.get('taxes')[-1].base_total diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index cc3528e9aa..349b8bb5b1 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -633,6 +633,45 @@ class TestPaymentEntry(unittest.TestCase): self.assertEqual(flt(expected_party_balance), party_balance) self.assertEqual(flt(expected_party_account_balance), party_account_balance) + def test_multi_currency_payment_entry_with_taxes(self): + payment_entry = create_payment_entry(party='_Test Supplier USD', paid_to = '_Test Payable USD - _TC', + save=True) + payment_entry.append('taxes', { + 'account_head': '_Test Account Service Tax - _TC', + 'charge_type': 'Actual', + 'tax_amount': 10, + 'add_deduct_tax': 'Add', + 'description': 'Test' + }) + + payment_entry.save() + self.assertEqual(payment_entry.base_total_taxes_and_charges, 10) + self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), flt(10 / payment_entry.target_exchange_rate, 2)) + +def create_payment_entry(**args): + payment_entry = frappe.new_doc('Payment Entry') + payment_entry.company = args.get('company') or '_Test Company' + payment_entry.payment_type = args.get('payment_type') or 'Pay' + payment_entry.party_type = args.get('party_type') or 'Supplier' + payment_entry.party = args.get('party') or '_Test Supplier' + payment_entry.paid_from = args.get('paid_from') or '_Test Bank - _TC' + payment_entry.paid_to = args.get('paid_to') or 'Creditors - _TC' + payment_entry.paid_amount = args.get('paid_amount') or 1000 + + payment_entry.setup_party_account_field() + payment_entry.set_missing_values() + payment_entry.set_exchange_rate() + payment_entry.received_amount = payment_entry.paid_amount / payment_entry.target_exchange_rate + payment_entry.reference_no = 'Test001' + payment_entry.reference_date = nowdate() + + if args.get('save'): + payment_entry.save() + if args.get('submit'): + payment_entry.submit() + + return payment_entry + def create_payment_terms_template(): create_payment_term('Basic Amount Receivable') diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 07591e7b1e..6d929e4386 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1595,6 +1595,56 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(expected_values[gle.account][1], gle.debit) self.assertEqual(expected_values[gle.account][2], gle.credit) + def test_rounding_adjustment_3(self): + si = create_sales_invoice(do_not_save=True) + si.items = [] + for d in [(1122, 2), (1122.01, 1), (1122.01, 1)]: + si.append("items", { + "item_code": "_Test Item", + "gst_hsn_code": "999800", + "warehouse": "_Test Warehouse - _TC", + "qty": d[1], + "rate": d[0], + "income_account": "Sales - _TC", + "cost_center": "_Test Cost Center - _TC" + }) + for tax_account in ["_Test Account VAT - _TC", "_Test Account Service Tax - _TC"]: + si.append("taxes", { + "charge_type": "On Net Total", + "account_head": tax_account, + "description": tax_account, + "rate": 6, + "cost_center": "_Test Cost Center - _TC", + "included_in_print_rate": 1 + }) + si.save() + si.submit() + self.assertEqual(si.net_total, 4007.16) + self.assertEqual(si.grand_total, 4488.02) + self.assertEqual(si.total_taxes_and_charges, 480.86) + self.assertEqual(si.rounding_adjustment, -0.02) + + expected_values = dict((d[0], d) for d in [ + [si.debit_to, 4488.0, 0.0], + ["_Test Account Service Tax - _TC", 0.0, 240.43], + ["_Test Account VAT - _TC", 0.0, 240.43], + ["Sales - _TC", 0.0, 4007.15], + ["Round Off - _TC", 0.01, 0] + ]) + + gl_entries = frappe.db.sql("""select account, debit, credit + from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s + order by account asc""", si.name, as_dict=1) + + debit_credit_diff = 0 + for gle in gl_entries: + self.assertEqual(expected_values[gle.account][0], gle.account) + self.assertEqual(expected_values[gle.account][1], gle.debit) + self.assertEqual(expected_values[gle.account][2], gle.credit) + debit_credit_diff += (gle.debit - gle.credit) + + self.assertEqual(debit_credit_diff, 0) + def test_sales_invoice_with_shipping_rule(self): from erpnext.accounts.doctype.shipping_rule.test_shipping_rule import create_shipping_rule diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py index 1d30934df9..8043a1b66f 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py @@ -55,5 +55,8 @@ def validate_disabled(doc): frappe.throw(_("Disabled template must not be default template")) def validate_for_tax_category(doc): + if not doc.tax_category: + return + if frappe.db.exists(doc.doctype, {"company": doc.company, "tax_category": doc.tax_category, "disabled": 0, "name": ["!=", doc.name]}): frappe.throw(_("A template with tax category {0} already exists. Only one template is allowed with each tax category").format(frappe.bold(doc.tax_category))) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index d24d56b4bb..0cd5e86a8c 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -274,7 +274,7 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision): debit_credit_diff += flt(d.credit) round_off_account_exists = True - if round_off_account_exists and abs(debit_credit_diff) <= (1.0 / (10**precision)): + if round_off_account_exists and abs(debit_credit_diff) < (1.0 / (10**precision)): gl_map.remove(round_off_gle) return diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 1b5f35efbb..2e7d3063cc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -316,6 +316,16 @@ class PurchaseOrder(BuyingController): 'target_ref_field': 'stock_qty', 'source_field': 'stock_qty' }) + self.status_updater.append({ + 'source_dt': 'Purchase Order Item', + 'target_dt': 'Packed Item', + 'target_field': 'ordered_qty', + 'target_parent_dt': 'Sales Order', + 'target_parent_field': '', + 'join_field': 'sales_order_packed_item', + 'target_ref_field': 'qty', + 'source_field': 'stock_qty' + }) def update_delivered_qty_in_sales_order(self): """Update delivered qty in Sales Order for drop ship""" diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 87cd57517e..a18c527644 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -63,6 +63,7 @@ "material_request_item", "sales_order", "sales_order_item", + "sales_order_packed_item", "supplier_quotation", "supplier_quotation_item", "col_break5", @@ -837,21 +838,30 @@ "label": "Product Bundle", "options": "Product Bundle", "read_only": 1 + }, + { + "fieldname": "sales_order_packed_item", + "fieldtype": "Data", + "label": "Sales Order Packed Item", + "no_copy": 1, + "print_hide": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-30 20:06:26.712097", + "modified": "2022-02-02 13:10:18.398976", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "quick_entry": 1, "search_fields": "item_name", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index c8e5eddfea..8972c32879 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -507,13 +507,41 @@ class StockController(AccountsController): "voucher_no": self.name, "company": self.company }) - if future_sle_exists(args): + + if future_sle_exists(args) or repost_required_for_queue(self): item_based_reposting = cint(frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting")) if item_based_reposting: create_item_wise_repost_entries(voucher_type=self.doctype, voucher_no=self.name) else: create_repost_item_valuation_entry(args) +def repost_required_for_queue(doc: StockController) -> bool: + """check if stock document contains repeated item-warehouse with queue based valuation. + + if queue exists for repeated items then SLEs need to reprocessed in background again. + """ + + consuming_sles = frappe.db.get_all("Stock Ledger Entry", + filters={ + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "actual_qty": ("<", 0), + "is_cancelled": 0 + }, + fields=["item_code", "warehouse", "stock_queue"] + ) + item_warehouses = [(sle.item_code, sle.warehouse) for sle in consuming_sles] + + unique_item_warehouses = set(item_warehouses) + + if len(unique_item_warehouses) == len(item_warehouses): + return False + + for sle in consuming_sles: + if sle.stock_queue != "[]": # using FIFO/LIFO valuation + return True + return False + @frappe.whitelist() def make_quality_inspections(doctype, docname, items): diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.json b/erpnext/hr/doctype/employee_advance/employee_advance.json index 04754530c3..b0501830cc 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.json +++ b/erpnext/hr/doctype/employee_advance/employee_advance.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "autoname": "naming_series:", - "creation": "2017-10-09 14:26:29.612365", + "creation": "2022-01-17 18:36:51.450395", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -121,7 +121,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Draft\nPaid\nUnpaid\nClaimed\nCancelled", + "options": "Draft\nPaid\nUnpaid\nClaimed\nReturned\nPartly Claimed and Returned\nCancelled", "read_only": 1 }, { @@ -200,7 +200,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-09-11 18:38:38.617478", + "modified": "2022-01-17 19:33:52.345823", "modified_by": "Administrator", "module": "HR", "name": "Employee Advance", @@ -237,5 +237,41 @@ "search_fields": "employee,employee_name", "sort_field": "modified", "sort_order": "DESC", + "states": [ + { + "color": "Red", + "custom": 1, + "title": "Draft" + }, + { + "color": "Green", + "custom": 1, + "title": "Paid" + }, + { + "color": "Orange", + "custom": 1, + "title": "Unpaid" + }, + { + "color": "Blue", + "custom": 1, + "title": "Claimed" + }, + { + "color": "Gray", + "title": "Returned" + }, + { + "color": "Yellow", + "title": "Partly Claimed and Returned" + }, + { + "color": "Red", + "custom": 1, + "title": "Cancelled" + } + ], + "title_field": "employee_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.py b/erpnext/hr/doctype/employee_advance/employee_advance.py index 7aac2b63ed..79d389d440 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/employee_advance.py @@ -27,19 +27,33 @@ class EmployeeAdvance(Document): def on_cancel(self): self.ignore_linked_doctypes = ('GL Entry') + self.set_status(update=True) + + def set_status(self, update=False): + precision = self.precision("paid_amount") + total_amount = flt(flt(self.claimed_amount) + flt(self.return_amount), precision) + status = None - def set_status(self): if self.docstatus == 0: - self.status = "Draft" - if self.docstatus == 1: - if self.claimed_amount and flt(self.claimed_amount) == flt(self.paid_amount): - self.status = "Claimed" - elif self.paid_amount and self.advance_amount == flt(self.paid_amount): - self.status = "Paid" + status = "Draft" + elif self.docstatus == 1: + if flt(self.claimed_amount) > 0 and flt(self.claimed_amount, precision) == flt(self.paid_amount, precision): + status = "Claimed" + elif flt(self.return_amount) > 0 and flt(self.return_amount, precision) == flt(self.paid_amount, precision): + status = "Returned" + elif flt(self.claimed_amount) > 0 and (flt(self.return_amount) > 0) and total_amount == flt(self.paid_amount, precision): + status = "Partly Claimed and Returned" + elif flt(self.paid_amount) > 0 and flt(self.advance_amount, precision) == flt(self.paid_amount, precision): + status = "Paid" else: - self.status = "Unpaid" + status = "Unpaid" elif self.docstatus == 2: - self.status = "Cancelled" + status = "Cancelled" + + if update: + self.db_set("status", status) + else: + self.status = status def set_total_advance_paid(self): gle = frappe.qb.DocType("GL Entry") @@ -85,9 +99,7 @@ class EmployeeAdvance(Document): self.db_set("paid_amount", paid_amount) self.db_set("return_amount", return_amount) - self.set_status() - frappe.db.set_value("Employee Advance", self.name , "status", self.status) - + self.set_status(update=True) def update_claimed_amount(self): claimed_amount = frappe.db.sql(""" @@ -103,8 +115,8 @@ class EmployeeAdvance(Document): frappe.db.set_value("Employee Advance", self.name, "claimed_amount", flt(claimed_amount)) self.reload() - self.set_status() - frappe.db.set_value("Employee Advance", self.name, "status", self.status) + self.set_status(update=True) + @frappe.whitelist() def get_pending_amount(employee, posting_date): @@ -222,7 +234,8 @@ def make_return_entry(employee, company, employee_advance_name, return_amount, 'reference_name': employee_advance_name, 'party_type': 'Employee', 'party': employee, - 'is_advance': 'Yes' + 'is_advance': 'Yes', + 'cost_center': erpnext.get_default_cost_center(company) }) bank_amount = flt(return_amount) if bank_cash_account.account_currency==currency \ @@ -233,7 +246,8 @@ def make_return_entry(employee, company, employee_advance_name, return_amount, "debit_in_account_currency": bank_amount, "account_currency": bank_cash_account.account_currency, "account_type": bank_cash_account.account_type, - "exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1 + "exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1, + "cost_center": erpnext.get_default_cost_center(company) }) return je.as_dict() diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py index 5f2e720eb4..e3c1487ca2 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 @@ -12,12 +12,21 @@ from erpnext.hr.doctype.employee_advance.employee_advance import ( EmployeeAdvanceOverPayment, create_return_through_additional_salary, make_bank_entry, + make_return_entry, +) +from erpnext.hr.doctype.expense_claim.expense_claim import get_advances +from erpnext.hr.doctype.expense_claim.test_expense_claim import ( + get_payable_account, + make_expense_claim, ) 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 class TestEmployeeAdvance(unittest.TestCase): + def setUp(self): + frappe.db.delete("Employee Advance") + def test_paid_amount_and_status(self): employee_name = make_employee("_T@employe.advance") advance = make_employee_advance(employee_name) @@ -52,9 +61,102 @@ class TestEmployeeAdvance(unittest.TestCase): self.assertEqual(advance.paid_amount, 0) self.assertEqual(advance.status, "Unpaid") + advance.cancel() + advance.reload() + self.assertEqual(advance.status, "Cancelled") + + def test_claimed_status(self): + # CLAIMED Status check, full amount claimed + 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 = get_advances_for_claim(claim, advance.name) + claim.save() + claim.submit() + + advance.reload() + self.assertEqual(advance.claimed_amount, 1000) + self.assertEqual(advance.status, "Claimed") + + # advance should not be shown in claims + advances = get_advances(claim.employee) + advances = [entry.name for entry in advances] + self.assertTrue(advance.name not in advances) + + # cancel claim; status should be Paid + claim.cancel() + advance.reload() + self.assertEqual(advance.claimed_amount, 0) + self.assertEqual(advance.status, "Paid") + + def test_partly_claimed_and_returned_status(self): + 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() + + # PARTLY CLAIMED AND RETURNED status check + # 500 Claimed, 500 Returned + claim = make_expense_claim(payable_account, 500, 500, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + + advance = make_employee_advance(claim.employee) + pe = make_payment_entry(advance) + pe.submit() + + claim = get_advances_for_claim(claim, advance.name, amount=500) + claim.save() + claim.submit() + + advance.reload() + self.assertEqual(advance.claimed_amount, 500) + self.assertEqual(advance.status, "Paid") + + entry = make_return_entry( + employee=advance.employee, + company=advance.company, + employee_advance_name=advance.name, + return_amount=flt(advance.paid_amount - advance.claimed_amount), + advance_account=advance.advance_account, + mode_of_payment=advance.mode_of_payment, + currency=advance.currency, + exchange_rate=advance.exchange_rate + ) + + entry = frappe.get_doc(entry) + entry.insert() + entry.submit() + + advance.reload() + self.assertEqual(advance.return_amount, 500) + self.assertEqual(advance.status, "Partly Claimed and Returned") + + # advance should not be shown in claims + advances = get_advances(claim.employee) + advances = [entry.name for entry in advances] + self.assertTrue(advance.name not in advances) + + # Cancel return entry; status should change to PAID + entry.cancel() + advance.reload() + self.assertEqual(advance.return_amount, 0) + self.assertEqual(advance.status, "Paid") + + # advance should be shown in claims + advances = get_advances(claim.employee) + advances = [entry.name for entry in advances] + self.assertTrue(advance.name in advances) + def test_repay_unclaimed_amount_from_salary(self): employee_name = make_employee("_T@employe.advance") advance = make_employee_advance(employee_name, {"repay_unclaimed_amount_from_salary": 1}) + pe = make_payment_entry(advance) + pe.submit() args = {"type": "Deduction"} create_salary_component("Advance Salary - Deduction", **args) @@ -82,11 +184,13 @@ class TestEmployeeAdvance(unittest.TestCase): advance.reload() self.assertEqual(advance.return_amount, 1000) + self.assertEqual(advance.status, "Returned") # update advance return amount on additional salary cancellation additional_salary.cancel() advance.reload() self.assertEqual(advance.return_amount, 700) + self.assertEqual(advance.status, "Paid") def tearDown(self): frappe.db.rollback() @@ -118,3 +222,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.js b/erpnext/hr/doctype/expense_claim/expense_claim.js index 047945787d..af80b63845 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.js +++ b/erpnext/hr/doctype/expense_claim/expense_claim.js @@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", { ['docstatus', '=', 1], ['employee', '=', frm.doc.employee], ['paid_amount', '>', 0], - ['status', '!=', 'Claimed'] + ['status', 'not in', ['Claimed', 'Returned', 'Partly Claimed and Returned']] ] }; }); diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py index 7e3898b7d5..fe04efbbab 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() @@ -341,18 +341,27 @@ def get_expense_claim_account(expense_claim_type, company): @frappe.whitelist() def get_advances(employee, advance_id=None): - if not advance_id: - condition = 'docstatus=1 and employee={0} and paid_amount > 0 and paid_amount > claimed_amount + return_amount'.format(frappe.db.escape(employee)) - else: - condition = 'name={0}'.format(frappe.db.escape(advance_id)) + advance = frappe.qb.DocType("Employee Advance") - return frappe.db.sql(""" - select - name, posting_date, paid_amount, claimed_amount, advance_account - from - `tabEmployee Advance` - where {0} - """.format(condition), as_dict=1) + query = ( + frappe.qb.from_(advance) + .select( + advance.name, advance.posting_date, advance.paid_amount, + advance.claimed_amount, advance.advance_account + ) + ) + + if not advance_id: + query = query.where( + (advance.docstatus == 1) + & (advance.employee == employee) + & (advance.paid_amount > 0) + & (advance.status.notin(["Claimed", "Returned", "Partly Claimed and Returned"])) + ) + else: + query = query.where(advance.name == advance_id) + + return query.run(as_dict=True) @frappe.whitelist() diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index d640f3fda7..37d2b9ff97 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -918,7 +918,7 @@ def validate_bom_no(item, bom_no): frappe.throw(_("BOM {0} does not belong to Item {1}").format(bom_no, item)) @frappe.whitelist() -def get_children(doctype, parent=None, is_root=False, **filters): +def get_children(parent=None, is_root=False, **filters): if not parent or parent=="BOM": frappe.msgprint(_('Please select a BOM')) return diff --git a/erpnext/manufacturing/doctype/operation/operation_dashboard.py b/erpnext/manufacturing/doctype/operation/operation_dashboard.py index 4fbcf4954e..9f7efa2b38 100644 --- a/erpnext/manufacturing/doctype/operation/operation_dashboard.py +++ b/erpnext/manufacturing/doctype/operation/operation_dashboard.py @@ -7,7 +7,7 @@ def get_data(): 'transactions': [ { 'label': _('Manufacture'), - 'items': ['BOM', 'Work Order', 'Job Card', 'Timesheet'] + 'items': ['BOM', 'Work Order', 'Job Card'] } ] } diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index c33e64674e..e8759f5528 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -232,7 +232,7 @@ frappe.ui.form.on('Production Plan', { }); }, combine_items: function (frm) { - frm.clear_table('prod_plan_references'); + frm.clear_table("prod_plan_references"); frappe.call({ method: "get_items", @@ -247,6 +247,13 @@ frappe.ui.form.on('Production Plan', { }); }, + combine_sub_items: (frm) => { + if (frm.doc.sub_assembly_items.length > 0) { + frm.clear_table("sub_assembly_items"); + frm.trigger("get_sub_assembly_items"); + } + }, + get_sub_assembly_items: function(frm) { frm.dirty(); diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 56cf2b4f08..3bfb764ba5 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -36,6 +36,7 @@ "prod_plan_references", "section_break_24", "get_sub_assembly_items", + "combine_sub_items", "sub_assembly_items", "material_request_planning", "include_non_stock_items", @@ -340,7 +341,6 @@ { "fieldname": "prod_plan_references", "fieldtype": "Table", - "hidden": 1, "label": "Production Plan Item Reference", "options": "Production Plan Item Reference" }, @@ -370,16 +370,23 @@ "fieldname": "to_delivery_date", "fieldtype": "Date", "label": "To Delivery Date" + }, + { + "default": "0", + "fieldname": "combine_sub_items", + "fieldtype": "Check", + "label": "Consolidate Sub Assembly Items" } ], "icon": "fa fa-calendar", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-09-06 18:35:59.642232", + "modified": "2022-02-23 17:16:10.629378", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 80003dab78..48cd753d75 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -21,7 +21,8 @@ from frappe.utils import ( ) from frappe.utils.csvutils import build_csv_response -from erpnext.manufacturing.doctype.bom.bom import get_children, validate_bom_no +from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children +from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -570,17 +571,28 @@ class ProductionPlan(Document): @frappe.whitelist() def get_sub_assembly_items(self, manufacturing_type=None): + "Fetch sub assembly items and optionally combine them." self.sub_assembly_items = [] + sub_assembly_items_store = [] # temporary store to process all subassembly items + for row in self.po_items: bom_data = [] get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty) self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) + sub_assembly_items_store.extend(bom_data) - self.sub_assembly_items.sort(key= lambda d: d.bom_level, reverse=True) - for idx, row in enumerate(self.sub_assembly_items, start=1): - row.idx = idx + if self.combine_sub_items: + # Combine subassembly items + sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store) + + sub_assembly_items_store.sort(key= lambda d: d.bom_level, reverse=True) # sort by bom level + + for idx, row in enumerate(sub_assembly_items_store): + row.idx = idx + 1 + self.append("sub_assembly_items", row) def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): + "Modify bom_data, set additional details." for data in bom_data: data.qty = data.stock_qty data.production_plan_item = row.name @@ -589,7 +601,32 @@ class ProductionPlan(Document): data.type_of_manufacturing = manufacturing_type or ("Subcontract" if data.is_sub_contracted_item else "In House") - self.append("sub_assembly_items", data) + def combine_subassembly_items(self, sub_assembly_items_store): + "Aggregate if same: Item, Warehouse, Inhouse/Outhouse Manu.g, BOM No." + key_wise_data = {} + for row in sub_assembly_items_store: + key = ( + row.get("production_item"), row.get("fg_warehouse"), + row.get("bom_no"), row.get("type_of_manufacturing") + ) + if key not in key_wise_data: + # intialise (item, wh, bom no, man.g type) wise dict + key_wise_data[key] = row + continue + + existing_row = key_wise_data[key] + if existing_row: + # if row with same (item, wh, bom no, man.g type) key, merge + existing_row.qty += flt(row.qty) + existing_row.stock_qty += flt(row.stock_qty) + existing_row.bom_level = max(existing_row.bom_level, row.bom_level) + continue + else: + # add row with key + key_wise_data[key] = row + + sub_assembly_items_store = [key_wise_data[key] for key in key_wise_data] # unpack into single level list + return sub_assembly_items_store def all_items_completed(self): all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 @@ -1031,7 +1068,7 @@ def get_item_data(item_code): } def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): - data = get_children('BOM', parent = bom_no) + data = get_bom_children(parent=bom_no) for d in data: if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index b2a41ff5b2..eeab788d5c 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -38,6 +38,9 @@ class TestProductionPlan(FrappeTestCase): if not frappe.db.get_value('BOM', {'item': item}): make_bom(item = item, raw_materials = raw_materials) + def tearDown(self) -> None: + frappe.db.rollback() + def test_production_plan_mr_creation(self): "Test if MRs are created for unavailable raw materials." pln = create_production_plan(item_code='Test Production Item 1') @@ -110,7 +113,7 @@ class TestProductionPlan(FrappeTestCase): item_code='Test Production Item 1', ignore_existing_ordered_qty=1 ) - self.assertTrue(len(pln.mr_items), 1) + self.assertTrue(len(pln.mr_items)) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) sr1.cancel() @@ -151,7 +154,7 @@ class TestProductionPlan(FrappeTestCase): use_multi_level_bom=0, ignore_existing_ordered_qty=0 ) - self.assertTrue(len(pln.mr_items), 0) + self.assertFalse(len(pln.mr_items)) sr1.cancel() sr2.cancel() @@ -258,6 +261,51 @@ class TestProductionPlan(FrappeTestCase): pln.reload() pln.cancel() + def test_production_plan_combine_subassembly(self): + """ + Test combining Sub assembly items belonging to the same BOM in Prod Plan. + 1) Red-Car -> Wheel (sub assembly) > BOM-WHEEL-001 + 2) Green-Car -> Wheel (sub assembly) > BOM-WHEEL-001 + """ + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + bom_tree_1 = { + "Red-Car": {"Wheel": {"Rubber": {}}} + } + bom_tree_2 = { + "Green-Car": {"Wheel": {"Rubber": {}}} + } + + parent_bom_1 = create_nested_bom(bom_tree_1, prefix="") + parent_bom_2 = create_nested_bom(bom_tree_2, prefix="") + + # make sure both boms use same subassembly bom + subassembly_bom = parent_bom_1.items[0].bom_no + frappe.db.set_value("BOM Item", parent_bom_2.items[0].name, "bom_no", subassembly_bom) + + plan = create_production_plan(item_code="Red-Car", use_multi_level_bom=1, do_not_save=True) + plan.append("po_items", { # Add Green-Car to Prod Plan + 'use_multi_level_bom': 1, + 'item_code': "Green-Car", + 'bom_no': frappe.db.get_value('Item', "Green-Car", 'default_bom'), + 'planned_qty': 1, + 'planned_start_date': now_datetime() + }) + plan.get_sub_assembly_items() + self.assertTrue(len(plan.sub_assembly_items), 2) + + plan.combine_sub_items = 1 + plan.get_sub_assembly_items() + + self.assertTrue(len(plan.sub_assembly_items), 1) # check if sub-assembly items merged + self.assertEqual(plan.sub_assembly_items[0].qty, 2.0) + self.assertEqual(plan.sub_assembly_items[0].stock_qty, 2.0) + + # change warehouse in one row, sub-assemblies should not merge + plan.po_items[0].warehouse = "Finished Goods - _TC" + plan.get_sub_assembly_items() + self.assertTrue(len(plan.sub_assembly_items), 2) + def test_pp_to_mr_customer_provided(self): " Test Material Request from Production Plan for Customer Provided Item." create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) @@ -532,6 +580,7 @@ class TestProductionPlan(FrappeTestCase): wip_warehouse='Work In Progress - _TC', fg_warehouse='Finished Goods - _TC', skip_transfer=1, + use_multi_level_bom=1, do_not_submit=True ) wo.production_plan = pln.name @@ -576,6 +625,7 @@ class TestProductionPlan(FrappeTestCase): wip_warehouse='Work In Progress - _TC', fg_warehouse='Finished Goods - _TC', skip_transfer=1, + use_multi_level_bom=1, do_not_submit=True ) wo.production_plan = pln.name diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index 33a313e32f..b480c70ad5 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -17,7 +17,7 @@ frappe.ui.form.on('Routing', { }, calculate_operating_cost: function(frm, child) { - const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2); + const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, precision("operating_cost", child)); frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost); } }); diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index 1c76634646..b207906c5e 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -20,7 +20,8 @@ class Routing(Document): for operation in self.operations: if not operation.hour_rate: operation.hour_rate = frappe.db.get_value("Workstation", operation.workstation, 'hour_rate') - operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, 2) + operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, + operation.precision("operating_cost")) def set_routing_id(self): sequence_id = 0 diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 549ec7b4a6..bc07d22e83 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1040,7 +1040,7 @@ def make_wo_order_test_record(**args): wo_order.scrap_warehouse = args.fg_warehouse or "_Test Scrap Warehouse - _TC" wo_order.company = args.company or "_Test Company" wo_order.stock_uom = args.stock_uom or "_Test UOM" - wo_order.use_multi_level_bom=0 + wo_order.use_multi_level_bom= args.use_multi_level_bom or 0 wo_order.skip_transfer=args.skip_transfer or 0 wo_order.get_items_and_operations_from_bom() wo_order.sales_order = args.sales_order or None diff --git a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py index bc481ca192..1fa14940a4 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py +++ b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py @@ -11,9 +11,9 @@ def get_data(): }, { 'label': _('Transaction'), - 'items': ['Work Order', 'Job Card', 'Timesheet'] + 'items': ['Work Order', 'Job Card',] } ], 'disable_create_buttons': ['BOM', 'Routing', 'Operation', - 'Work Order', 'Job Card', 'Timesheet'] + 'Work Order', 'Job Card',] } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 12af8f800a..13f0e7b872 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -356,4 +356,5 @@ erpnext.patches.v14_0.delete_amazon_mws_doctype erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs erpnext.patches.v14_0.update_batch_valuation_flag -erpnext.patches.v14_0.delete_non_profit_doctypes \ No newline at end of file +erpnext.patches.v14_0.delete_non_profit_doctypes +erpnext.patches.v14_0.update_employee_advance_status \ No newline at end of file diff --git a/erpnext/patches/v14_0/update_employee_advance_status.py b/erpnext/patches/v14_0/update_employee_advance_status.py new file mode 100644 index 0000000000..a20e35a9f6 --- /dev/null +++ b/erpnext/patches/v14_0/update_employee_advance_status.py @@ -0,0 +1,26 @@ +import frappe + + +def execute(): + frappe.reload_doc('hr', 'doctype', 'employee_advance') + + advance = frappe.qb.DocType('Employee Advance') + (frappe.qb + .update(advance) + .set(advance.status, 'Returned') + .where( + (advance.docstatus == 1) + & ((advance.return_amount) & (advance.paid_amount == advance.return_amount)) + & (advance.status == 'Paid') + ) + ).run() + + (frappe.qb + .update(advance) + .set(advance.status, 'Partly Claimed and Returned') + .where( + (advance.docstatus == 1) + & ((advance.claimed_amount & advance.return_amount) & (advance.paid_amount == (advance.return_amount + advance.claimed_amount))) + & (advance.status == 'Paid') + ) + ).run() \ No newline at end of file diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py index bf8bd05fcc..d618568416 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.py +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py @@ -105,6 +105,8 @@ class AdditionalSalary(Document): return_amount += self.amount frappe.db.set_value("Employee Advance", self.ref_docname, "return_amount", return_amount) + advance = frappe.get_doc("Employee Advance", self.ref_docname) + advance.set_status(update=True) def update_employee_referral(self, cancel=False): if self.ref_doctype == "Employee Referral": diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index f615f051f0..453d46c7c4 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -116,7 +116,7 @@ frappe.ui.form.on("Timesheet", { currency: function(frm) { let base_currency = frappe.defaults.get_global_default('currency'); - if (base_currency != frm.doc.currency) { + if (frm.doc.currency && (base_currency != frm.doc.currency)) { frappe.call({ method: "erpnext.setup.utils.get_exchange_rate", args: { diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index eb98e6c0bf..f80eaf2757 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -562,6 +562,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex var me = this; var dialog = new frappe.ui.Dialog({ title: __("Select Items"), + size: "large", fields: [ { "fieldtype": "Check", @@ -663,7 +664,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } else { let po_items = []; me.frm.doc.items.forEach(d => { - let pending_qty = (flt(d.stock_qty) - flt(d.ordered_qty)) / flt(d.conversion_factor); + let ordered_qty = me.get_ordered_qty(d, me.frm.doc); + let pending_qty = (flt(d.stock_qty) - ordered_qty) / flt(d.conversion_factor); if (pending_qty > 0) { po_items.push({ "doctype": "Sales Order Item", @@ -689,6 +691,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex dialog.show(); } + get_ordered_qty(item, so) { + let ordered_qty = item.ordered_qty; + if (so.packed_items) { + // 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) { + ordered_qty = packed_items.reduce( + (sum, pi) => sum + flt(pi.ordered_qty), + 0 + ); + ordered_qty = ordered_qty / packed_items.length; + } + } + return ordered_qty; + } + hold_sales_order(){ var me = this; var d = new frappe.ui.Dialog({ diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 0f5b1e3b89..abbb3c9b90 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -877,6 +877,9 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) target.project = source_parent.project + def update_item_for_packed_item(source, target, source_parent): + target.qty = flt(source.qty) - flt(source.ordered_qty) + # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) doc = get_mapped_doc("Sales Order", source_name, { "Sales Order": { @@ -920,6 +923,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "Packed Item": { "doctype": "Purchase Order Item", "field_map": [ + ["name", "sales_order_packed_item"], ["parent", "sales_order"], ["uom", "uom"], ["conversion_factor", "conversion_factor"], @@ -934,6 +938,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "supplier", "pricing_rules" ], + "postprocess": update_item_for_packed_item, "condition": lambda doc: doc.parent_item in items_to_map } }, target_doc, set_missing_values) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index f5a34c0eec..b5284793e1 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -959,6 +959,42 @@ class TestSalesOrder(FrappeTestCase): self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1") self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2") + def test_purchase_order_updates_packed_item_ordered_qty(self): + """ + Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order + """ + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + + product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) + make_item("_Test Bundle Item 1", {"is_stock_item": 1}) + make_item("_Test Bundle Item 2", {"is_stock_item": 1}) + + make_product_bundle("_Test Product Bundle", + ["_Test Bundle Item 1", "_Test Bundle Item 2"]) + + so_items = [ + { + "item_code": product_bundle.item_code, + "warehouse": "", + "qty": 2, + "rate": 400, + "delivered_by_supplier": 1, + "supplier": '_Test Supplier' + } + ] + + so = make_sales_order(item_list=so_items) + + purchase_order = make_purchase_order(so.name, selected_items=so_items) + purchase_order.supplier = "_Test Supplier" + purchase_order.set_warehouse = "_Test Warehouse - _TC" + purchase_order.save() + purchase_order.submit() + + so.reload() + self.assertEqual(so.packed_items[0].ordered_qty, 2) + self.assertEqual(so.packed_items[1].ordered_qty, 2) + def test_reserved_qty_for_closing_so(self): bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"]) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 95b1e8b9c6..36ad8fec9f 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -3,7 +3,6 @@ import json -import os import frappe import frappe.defaults @@ -422,14 +421,14 @@ def get_name_with_abbr(name, company): return " - ".join(parts) def install_country_fixtures(company, country): - path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) - if os.path.exists(path.encode("utf-8")): - try: - module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) - frappe.get_attr(module_name)(company, False) - except Exception as e: - frappe.log_error() - frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country))) + try: + module_name = f"erpnext.regional.{frappe.scrub(country)}.setup.setup" + frappe.get_attr(module_name)(company, False) + except ImportError: + pass + except Exception: + frappe.log_error() + frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country))) def update_company_current_month_sales(company): 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 dbaefc1e11..6dc4fee569 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 @@ -11,6 +11,7 @@ from erpnext.accounts.doctype.account.test_account import create_account, get_in from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.utils import update_gl_entries_after from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_gl_entries, make_purchase_receipt, @@ -177,6 +178,53 @@ class TestLandedCostVoucher(FrappeTestCase): self.assertEqual(serial_no.purchase_rate - serial_no_rate, 5.0) self.assertEqual(serial_no.warehouse, "Stores - TCP1") + def test_serialized_lcv_delivered(self): + """In some cases you'd want to deliver before you can know all the + landed costs, this should be allowed for serial nos too. + + Case: + - receipt a serial no @ X rate + - delivery the serial no @ X rate + - add LCV to receipt X + Y + - LCV should be successful + - delivery should reflect X+Y valuation. + """ + serial_no = "LCV_TEST_SR_NO" + item_code = "_Test Serialized Item" + warehouse = "Stores - TCP1" + + pr = make_purchase_receipt(company="_Test Company with perpetual inventory", + warehouse=warehouse, qty=1, rate=200, + item_code=item_code, serial_no=serial_no) + + serial_no_rate = frappe.db.get_value("Serial No", serial_no, "purchase_rate") + + # deliver it before creating LCV + dn = create_delivery_note(item_code=item_code, + company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', + serial_no=serial_no, qty=1, rate=500, + cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1") + + charges = 10 + create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges) + + new_purchase_rate = serial_no_rate + charges + + serial_no = frappe.db.get_value("Serial No", serial_no, + ["warehouse", "purchase_rate"], as_dict=1) + + self.assertEqual(serial_no.purchase_rate, new_purchase_rate) + + stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + filters={ + "voucher_no": dn.name, + "voucher_type": dn.doctype, + "is_cancelled": 0 # LCV cancels with same name. + }, + fieldname="stock_value_difference") + + # reposting should update the purchase rate in future delivery + self.assertEqual(stock_value_difference, -new_purchase_rate) def test_landed_cost_voucher_for_odd_numbers (self): pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1", do_not_save=True) diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index d2d4789765..d6e2e9ce2d 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -26,6 +26,7 @@ "section_break_13", "actual_qty", "projected_qty", + "ordered_qty", "column_break_16", "incoming_rate", "page_break", @@ -224,13 +225,21 @@ "label": "Rate", "print_hide": 1, "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "ordered_qty", + "fieldtype": "Float", + "label": "Ordered Qty", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-28 16:03:30.780111", + "modified": "2022-02-22 12:57:45.325488", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index a24acb1bd8..fa28f2252d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -161,6 +161,15 @@ class TestPurchaseReceipt(FrappeTestCase): qty=abs(existing_bin_qty) ) + existing_bin_qty, existing_bin_stock_value = frappe.db.get_value( + "Bin", + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC" + }, + ["actual_qty", "stock_value"] + ) + pr = make_purchase_receipt() stock_value_difference = frappe.db.get_value( 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 01d25b2e86..684a8d4d7c 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 @@ -389,10 +389,13 @@ class TestStockLedgerEntry(FrappeTestCase): ) - def assertSLEs(self, doc, expected_sles): + def assertSLEs(self, doc, expected_sles, sle_filters=None): """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" - sles = frappe.get_all("Stock Ledger Entry", fields=["*"], - filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0}, + + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} + if sle_filters: + filters.update(sle_filters) + sles = frappe.get_all("Stock Ledger Entry", fields=["*"], filters=filters, order_by="timestamp(posting_date, posting_time), creation") for exp_sle, act_sle in zip(expected_sles, sles): @@ -665,6 +668,78 @@ class TestStockLedgerEntry(FrappeTestCase): {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, ])) + def test_fifo_dependent_consumption(self): + item = make_item("_TestFifoTransferRates") + source = "_Test Warehouse - _TC" + target = "Stores - _TC" + + rates = [10 * i for i in range(1, 20)] + + receipt = make_stock_entry(item_code=item.name, target=source, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False) + row.basic_rate = rate + receipt.append("items", row) + + receipt.save() + receipt.submit() + + expected_queues = [] + for idx, rate in enumerate(rates, start=1): + expected_queues.append( + {"stock_queue": [[10, 10 * i] for i in range(1, idx + 1)]} + ) + self.assertSLEs(receipt, expected_queues) + + transfer = make_stock_entry(item_code=item.name, source=source, target=target, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(transfer.items[0], ignore_no_copy=False) + transfer.append("items", row) + + transfer.save() + transfer.submit() + + # same exact queue should be transferred + self.assertSLEs(transfer, expected_queues, sle_filters={"warehouse": target}) + + def test_fifo_multi_item_repack_consumption(self): + rm = make_item("_TestFifoRepackRM") + packed = make_item("_TestFifoRepackFinished") + warehouse = "_Test Warehouse - _TC" + + rates = [10 * i for i in range(1, 5)] + + receipt = make_stock_entry(item_code=rm.name, target=warehouse, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False) + row.basic_rate = rate + receipt.append("items", row) + + receipt.save() + receipt.submit() + + repack = make_stock_entry(item_code=rm.name, source=warehouse, qty=10, + do_not_save=True, rate=10, purpose="Repack") + for rate in rates[1:]: + row = frappe.copy_doc(repack.items[0], ignore_no_copy=False) + repack.append("items", row) + + repack.append("items", { + "item_code": packed.name, + "t_warehouse": warehouse, + "qty": 1, + "transfer_qty": 1, + }) + + repack.save() + repack.submit() + + # same exact queue should be transferred + self.assertSLEs(repack, [ + {"incoming_rate": sum(rates) * 10} + ], sle_filters={"item_code": packed.name}) + + def create_repack_entry(**args): args = frappe._dict(args) repack = frappe.new_doc("Stock Entry") diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index 05076b51a3..c695d541bf 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -244,7 +244,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2021-12-03 04:40:06.414630", + "modified": "2022-03-01 02:37:48.034944", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", @@ -301,5 +301,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "warehouse_name" + "states": [], + "title_field": "warehouse_name", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 7826d34422..1ba2482935 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -21,6 +21,7 @@ SLE_FIELDS = ( "stock_value", "stock_value_difference", "valuation_rate", + "voucher_detail_no", ) @@ -66,7 +67,9 @@ def add_invariant_check_fields(sles): balance_qty += sle.actual_qty balance_stock_value += sle.stock_value_difference if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: - balance_qty = sle.qty_after_transaction + balance_qty = frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "qty") + if balance_qty is None: + balance_qty = sle.qty_after_transaction sle.fifo_queue_qty = fifo_qty sle.fifo_stock_value = fifo_value diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 1b90086440..69755529e5 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -28,6 +28,16 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError): def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): + """ Create SL entries from SL entry dicts + + args: + - allow_negative_stock: disable negative stock valiations if true + - via_landed_cost_voucher: landed cost voucher cancels and reposts + entries of purchase document. This flag is used to identify if + cancellation and repost is happening via landed cost voucher, in + such cases certain validations need to be ignored (like negative + stock) + """ from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: cancel = sl_entries[0].get("is_cancelled") @@ -39,7 +49,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc future_sle_exists(args, sl_entries) for sle in sl_entries: - if sle.serial_no: + if sle.serial_no and not via_landed_cost_voucher: validate_serial_no(sle) if cancel: diff --git a/erpnext/templates/includes/footer/footer_powered.html b/erpnext/templates/includes/footer/footer_powered.html index 82b2716a92..faf5e9278c 100644 --- a/erpnext/templates/includes/footer/footer_powered.html +++ b/erpnext/templates/includes/footer/footer_powered.html @@ -1,27 +1 @@ -{% set domains = frappe.get_doc("Domain Settings").active_domains %} -{% set links = { - 'Manufacturing': '/manufacturing', - 'Services': '/services', - 'Retail': '/retail', - 'Distribution': '/distribution', - 'Non Profit': '/non-profit', - 'Education': '/education', - 'Healthcare': '/healthcare', - 'Agriculture': '/agriculture', - 'Hospitality': '' -} %} - -{% set link = '' %} -{% set label = '' %} -{% if domains %} - {% set label = domains[0].domain %} - {% set link = links[label] %} -{% endif %} - -{% if label == "Services" %} - {% set label = "Service" %} -{% endif %} - - - -Powered by ERPNext - {{ '' if domains else 'Open Source' }} ERP Software {{ ('for ' + label + ' Companies') if domains else '' }} +Powered by ERPNext diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py index 3299c8885f..38f2c16d93 100644 --- a/erpnext/tests/test_point_of_sale.py +++ b/erpnext/tests/test_point_of_sale.py @@ -25,7 +25,7 @@ class TestPointOfSale(unittest.TestCase): Test Stock and Service Item Search. """ - pos_profile = make_pos_profile() + pos_profile = make_pos_profile(name="Test POS Profile for Search") item1 = make_item("Test Search Stock Item", {"is_stock_item": 1}) make_stock_entry( item_code="Test Search Stock Item",