diff --git a/.github/workflows/semantic-commits.yml b/.github/workflows/semantic-commits.yml index 1744bc33a9e..da3d564e66c 100644 --- a/.github/workflows/semantic-commits.yml +++ b/.github/workflows/semantic-commits.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: 14 + node-version: 20 check-latest: true - name: Check commit titles diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 643047e982d..813c70806f4 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -22,6 +22,8 @@ "is_paid", "is_return", "return_against", + "update_billed_amount_in_purchase_order", + "update_billed_amount_in_purchase_receipt", "apply_tds", "tax_withholding_category", "amended_from", @@ -410,6 +412,20 @@ "read_only": 1, "search_index": 1 }, + { + "default": "0", + "depends_on": "eval: doc.is_return", + "fieldname": "update_billed_amount_in_purchase_order", + "fieldtype": "Check", + "label": "Update Billed Amount in Purchase Order" + }, + { + "default": "1", + "depends_on": "eval: doc.is_return", + "fieldname": "update_billed_amount_in_purchase_receipt", + "fieldtype": "Check", + "label": "Update Billed Amount in Purchase Receipt" + }, { "fieldname": "section_addresses", "fieldtype": "Section Break", @@ -1594,7 +1610,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2023-11-03 15:47:30.319200", + "modified": "2024-02-25 11:20:28.366808", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 802ac8080a9..f6fcd7e70e0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -514,6 +514,11 @@ class PurchaseInvoice(BuyingController): super(PurchaseInvoice, self).on_submit() self.check_prev_docstatus() + + if self.is_return and not self.update_billed_amount_in_purchase_order: + # NOTE status updating bypassed for is_return + self.status_updater = [] + self.update_status_updater_args() self.update_prevdoc_status() @@ -1264,6 +1269,10 @@ class PurchaseInvoice(BuyingController): self.check_on_hold_or_closed_status() + if self.is_return and not self.update_billed_amount_in_purchase_order: + # NOTE status updating bypassed for is_return + self.status_updater = [] + self.update_status_updater_args() self.update_prevdoc_status() @@ -1357,6 +1366,9 @@ class PurchaseInvoice(BuyingController): frappe.throw(_("Supplier Invoice No exists in Purchase Invoice {0}").format(pi)) def update_billing_status_in_pr(self, update_modified=True): + if self.is_return and not self.update_billed_amount_in_purchase_receipt: + return + updated_pr = [] po_details = [] diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py index 9211b286c7d..28355964cfa 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -70,6 +70,7 @@ class RepostAccountingLedger(Document): ).append(gle.update({"old": True})) def generate_preview_data(self): + frappe.flags.through_repost_accounting_ledger = True self.gl_entries = [] self.get_existing_ledger_entries() for x in self.vouchers: @@ -123,6 +124,7 @@ class RepostAccountingLedger(Document): @frappe.whitelist() def start_repost(account_repost_doc=str) -> None: + frappe.flags.through_repost_accounting_ledger = True if account_repost_doc: repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 19f52ad1e77..730c47569fa 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -90,7 +90,7 @@ class SalesInvoice(SellingController): super(SalesInvoice, self).validate() self.validate_auto_set_posting_time() - if not self.is_pos: + if not (self.is_pos or self.is_debit_note): self.so_dn_required() self.set_tax_withholding() @@ -1293,9 +1293,7 @@ class SalesInvoice(SellingController): "credit_in_account_currency": payment_mode.base_amount if self.party_account_currency == self.company_currency else payment_mode.amount, - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, + "against_voucher": self.name, "against_voucher_type": self.doctype, "cost_center": self.cost_center, }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index abf0fc5c0f3..ce5456da5d3 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1081,6 +1081,44 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(pos.grand_total, 100.0) self.assertEqual(pos.write_off_amount, 10) + def test_ledger_entries_of_return_pos_invoice(self): + make_pos_profile() + + pos = create_sales_invoice(do_not_save=True) + pos.is_pos = 1 + pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100}) + pos.save().submit() + self.assertEqual(pos.outstanding_amount, 0.0) + self.assertEqual(pos.status, "Paid") + + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + pos_return = make_sales_return(pos.name) + pos_return.save().submit() + pos_return.reload() + pos.reload() + self.assertEqual(pos_return.is_return, 1) + self.assertEqual(pos_return.return_against, pos.name) + self.assertEqual(pos_return.outstanding_amount, 0.0) + self.assertEqual(pos_return.status, "Return") + self.assertEqual(pos.outstanding_amount, 0.0) + self.assertEqual(pos.status, "Credit Note Issued") + + expected = ( + ("Cash - _TC", 0.0, 100.0, pos_return.name, None), + ("Debtors - _TC", 0.0, 100.0, pos_return.name, pos_return.name), + ("Debtors - _TC", 100.0, 0.0, pos_return.name, pos_return.name), + ("Sales - _TC", 100.0, 0.0, pos_return.name, None), + ) + res = frappe.db.get_all( + "GL Entry", + filters={"voucher_no": pos_return.name, "is_cancelled": 0}, + fields=["account", "debit", "credit", "voucher_no", "against_voucher"], + order_by="account, debit, credit", + as_list=1, + ) + self.assertEqual(expected, res) + def test_pos_with_no_gl_entry_for_change_amount(self): frappe.db.set_value("Accounts Settings", None, "post_change_gl_entries", 0) diff --git a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py index f404d9981a3..57f66dd21db 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py @@ -8,6 +8,7 @@ from frappe.utils import today from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): @@ -49,6 +50,16 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): ) return pe + def create_sales_order(self): + so = make_sales_order( + company=self.company, + customer=self.customer, + item=self.item, + rate=100, + transaction_date=today(), + ) + return so + def test_01_unreconcile_invoice(self): si1 = self.create_sales_invoice() si2 = self.create_sales_invoice() @@ -314,3 +325,41 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): ), 1, ) + + def test_05_unreconcile_order(self): + so = self.create_sales_order() + + pe = self.create_payment_entry() + # Allocation payment against Sales Order + pe.paid_amount = 100 + pe.append( + "references", + {"reference_doctype": so.doctype, "reference_name": so.name, "allocated_amount": 100}, + ) + pe.save().submit() + + # Assert 'Advance Paid' + so.reload() + self.assertEqual(so.advance_paid, 100) + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payment", + "company": self.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 1) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([so.name], allocations) + # unreconcile so + unreconcile.save().submit() + + # Assert 'Advance Paid' + so.reload() + pe.reload() + self.assertEqual(so.advance_paid, 0) + self.assertEqual(len(pe.references), 0) + self.assertEqual(pe.unallocated_amount, 100) diff --git a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py index 77906a78332..dd714573b1b 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py @@ -63,6 +63,9 @@ class UnreconcilePayment(Document): update_voucher_outstanding( alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party ) + if doc.doctype in frappe.get_hooks("advance_payment_doctypes"): + doc.set_total_advance_paid() + frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True) diff --git a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py index 3f178f4715c..eaeaa62d9a2 100644 --- a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py +++ b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py @@ -163,7 +163,7 @@ def get_entries(filters): """select voucher_type, voucher_no, party_type, party, posting_date, debit, credit, remarks, against_voucher from `tabGL Entry` - where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') {0} + where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') and is_cancelled = 0 {0} """.format( get_conditions(filters) ), diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index ba5cdbe6567..c0963150b1f 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -242,7 +242,7 @@ def get_columns(filters): "width": 120, }, { - "label": _("Tax Amount"), + "label": _("TDS Amount") if filters.get("party_type") == "Supplier" else _("TCS Amount"), "fieldname": "tax_amount", "fieldtype": "Float", "width": 120, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 739a989c79e..3a498ee271b 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -940,6 +940,38 @@ class TestPurchaseOrder(FrappeTestCase): self.assertRaises(frappe.ValidationError, po.save) + def test_po_billed_amount_against_return_entry(self): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note + + # Create a Purchase Order and Fully Bill it + po = create_purchase_order() + pi = make_pi_from_po(po.name) + pi.insert() + pi.submit() + + # Debit Note - 50% Qty & enable updating PO billed amount + pi_return = make_debit_note(pi.name) + pi_return.items[0].qty = -5 + pi_return.update_billed_amount_in_purchase_order = 1 + pi_return.submit() + + # Check if the billed amount reduced + po.reload() + self.assertEqual(po.per_billed, 50) + + pi_return.reload() + pi_return.cancel() + + # Debit Note - 50% Qty & disable updating PO billed amount + pi_return = make_debit_note(pi.name) + pi_return.items[0].qty = -5 + pi_return.update_billed_amount_in_purchase_order = 0 + pi_return.submit() + + # Check if the billed amount stayed the same + po.reload() + self.assertEqual(po.per_billed, 100) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f642cde73c3..560e7715e68 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -201,7 +201,8 @@ class AccountsController(TransactionBase): ) ) - if self.get("is_return") and self.get("return_against"): + if self.get("is_return") and self.get("return_against") and not self.get("is_pos"): + # if self.get("is_return") and self.get("return_against"): document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note" frappe.msgprint( _( @@ -324,6 +325,12 @@ class AccountsController(TransactionBase): ple = frappe.qb.DocType("Payment Ledger Entry") frappe.qb.from_(ple).delete().where( (ple.voucher_type == self.doctype) & (ple.voucher_no == self.name) + | ( + (ple.against_voucher_type == self.doctype) + & (ple.against_voucher_no == self.name) + & ple.delinked + == 1 + ) ).run() frappe.db.sql( "delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 4a627696032..1abf95f5953 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -811,7 +811,8 @@ class BuyingController(SubcontractingController): if self.doctype == "Purchase Invoice" and not self.get("update_stock"): return - frappe.db.sql("delete from `tabAsset Movement` where reference_name=%s", self.name) + asset_movement = frappe.db.get_value("Asset Movement", {"reference_name": self.name}, "name") + frappe.delete_doc("Asset Movement", asset_movement, force=1) def validate_schedule_date(self): if not self.get("items"): diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index c9b12f4f723..34abe9f764f 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -31,7 +31,8 @@ class SellingController(StockController): def validate(self): super(SellingController, self).validate() self.validate_items() - self.validate_max_discount() + if not self.get("is_debit_note"): + self.validate_max_discount() self.validate_selling_price() self.set_qty_as_per_stock_uom() self.set_po_nos(for_validate=True) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index e24618af103..2881c15a14d 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -1005,7 +1005,7 @@ def get_itemised_tax_breakup_data(doc): for item_code, taxes in itemised_tax.items(): itemised_tax_data.append( frappe._dict( - {"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code), **taxes} + {"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code, 0), **taxes} ) ) diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index dea3f2dd36d..4f7436ff9e4 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -41,7 +41,9 @@ class SalesPipelineAnalytics(object): month_list = self.get_month_list() for month in month_list: - self.columns.append({"fieldname": month, "fieldtype": based_on, "label": month, "width": 200}) + self.columns.append( + {"fieldname": month, "fieldtype": based_on, "label": _(month), "width": 200} + ) elif self.filters.get("range") == "Quarterly": for quarter in range(1, 5): @@ -156,7 +158,7 @@ class SalesPipelineAnalytics(object): for column in self.columns: if column["fieldname"] != "opportunity_owner" and column["fieldname"] != "sales_stage": - labels.append(column["fieldname"]) + labels.append(_(column["fieldname"])) self.chart = {"data": {"labels": labels, "datasets": datasets}, "type": "line"} diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 5354d0d6c13..af1052a3700 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -10,7 +10,6 @@ from frappe.model.document import Document from frappe.utils import add_months, formatdate, getdate, sbool, today from plaid.errors import ItemError -from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_connector import PlaidConnector @@ -74,9 +73,15 @@ def add_bank_accounts(response, bank, company): bank = json.loads(bank) result = [] - default_gl_account = get_default_bank_cash_account(company, "Bank") - if not default_gl_account: - frappe.throw(_("Please setup a default bank account for company {0}").format(company)) + parent_gl_account = frappe.db.get_all( + "Account", {"company": company, "account_type": "Bank", "is_group": 1, "disabled": 0} + ) + if not parent_gl_account: + frappe.throw( + _( + "Please setup and enable a group account with the Account Type - {0} for the company {1}" + ).format(frappe.bold("Bank"), company) + ) for account in response["accounts"]: acc_type = frappe.db.get_value("Bank Account Type", account["type"]) @@ -92,11 +97,22 @@ def add_bank_accounts(response, bank, company): if not existing_bank_account: try: + gl_account = frappe.get_doc( + { + "doctype": "Account", + "account_name": account["name"] + " - " + response["institution"]["name"], + "parent_account": parent_gl_account[0].name, + "account_type": "Bank", + "company": company, + } + ) + gl_account.insert(ignore_if_duplicate=True) + new_account = frappe.get_doc( { "doctype": "Bank Account", "bank": bank["bank_name"], - "account": default_gl_account.account, + "account": gl_account.name, "account_name": account["name"], "account_type": account.get("type", ""), "account_subtype": account.get("subtype", ""), diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index 6d34a204cd2..7312d8a5ad4 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -7,7 +7,6 @@ import unittest import frappe from frappe.utils.response import json_handler -from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import ( add_account_subtype, add_account_type, @@ -43,7 +42,7 @@ class TestPlaidSettings(unittest.TestCase): add_account_subtype("loan") self.assertEqual(frappe.get_doc("Bank Account Subtype", "loan").name, "loan") - def test_default_bank_account(self): + def test_parent_bank_account_validation(self): if not frappe.db.exists("Bank", "Citi"): frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert() @@ -71,12 +70,19 @@ class TestPlaidSettings(unittest.TestCase): bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler) company = frappe.db.get_single_value("Global Defaults", "default_company") - frappe.db.set_value("Company", company, "default_bank_account", None) + group_bank_account = frappe.db.get_all( + "Account", {"company": company, "account_type": "Bank", "is_group": 1}, pluck="name" + ) + if group_bank_account: + frappe.db.set_value("Account", group_bank_account[0], "disabled", 1) self.assertRaises( frappe.ValidationError, add_bank_accounts, response=bank_accounts, bank=bank, company=company ) + if group_bank_account: + frappe.db.set_value("Account", group_bank_account[0], "disabled", 0) + def test_new_transaction(self): if not frappe.db.exists("Bank", "Citi"): frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert() @@ -106,14 +112,6 @@ class TestPlaidSettings(unittest.TestCase): bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler) company = frappe.db.get_single_value("Global Defaults", "default_company") - if frappe.db.get_value("Company", company, "default_bank_account") is None: - frappe.db.set_value( - "Company", - company, - "default_bank_account", - get_default_bank_cash_account(company, "Cash").get("account"), - ) - add_bank_accounts(bank_accounts, bank, company) transactions = { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f47858ba9ff..1823efbbaaa 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -163,7 +163,7 @@ class JobCard(Document): for row in self.sub_operations: self.total_completed_qty += row.completed_qty - def get_overlap_for(self, args, check_next_available_slot=False): + def get_overlap_for(self, args): production_capacity = 1 jc = frappe.qb.DocType("Job Card") @@ -175,9 +175,6 @@ class JobCard(Document): ((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)), ] - if check_next_available_slot: - time_conditions.append(((jctl.from_time >= args.from_time) & (jctl.to_time >= args.to_time))) - query = ( frappe.qb.from_(jctl) .from_(jc) @@ -279,13 +276,29 @@ class JobCard(Document): self.check_workstation_time(row) def validate_overlap_for_workstation(self, args, row): + if args.get("to_time") and get_datetime(args.to_time) < get_datetime(args.from_time): + args.to_time = add_to_date(row.planned_start_time, minutes=row.remaining_time_in_mins) + # get the last record based on the to time from the job card - data = self.get_overlap_for(args, check_next_available_slot=True) + data = self.get_overlap_for(args) + + if not data: + row.planned_start_time = args.from_time + return + if data: if not self.workstation: self.workstation = data.workstation - row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) + if data.get("planned_start_time"): + args.planned_start_time = get_datetime(data.planned_start_time) + else: + args.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) + + args.from_time = args.planned_start_time + args.to_time = add_to_date(args.planned_start_time, minutes=row.remaining_time_in_mins) + + self.validate_overlap_for_workstation(args, row) def check_workstation_time(self, row): workstation_doc = frappe.get_cached_doc("Workstation", self.workstation) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index c9c474db7f0..667ece2077e 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -518,6 +518,12 @@ frappe.ui.form.on("Production Plan Sales Order", { } }); +frappe.ui.form.on("Production Plan Sub Assembly Item", { + fg_warehouse(frm, cdt, cdn) { + erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "sub_assembly_items", "fg_warehouse"); + }, +}) + frappe.tour['Production Plan'] = [ { fieldname: "get_items_from", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 54c3893928b..84bbad58c38 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -421,9 +421,11 @@ "fieldtype": "Column Break" }, { + "description": "When a parent warehouse is chosen, the system conducts stock checks against the associated child warehouses", "fieldname": "sub_assembly_warehouse", "fieldtype": "Link", "label": "Sub Assembly Warehouse", + "mandatory_depends_on": "eval:doc.skip_available_sub_assembly_item === 1", "options": "Warehouse" }, { @@ -437,7 +439,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-02-11 15:42:47.642481", + "modified": "2024-02-27 13:34:20.692211", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index aea6c987177..7efc4f75e5b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -817,8 +817,8 @@ class ProductionPlan(Document): sub_assembly_items_store = [] # temporary store to process all subassembly items for row in self.po_items: - if self.skip_available_sub_assembly_item and not row.warehouse: - frappe.throw(_("Row #{0}: Please select the FG Warehouse in Assembly Items").format(row.idx)) + if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse: + frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx)) if not row.item_code: frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx)) @@ -828,15 +828,24 @@ class ProductionPlan(Document): bom_data = [] - warehouse = ( - (self.sub_assembly_warehouse or row.warehouse) - if self.skip_available_sub_assembly_item - else None - ) + warehouse = (self.sub_assembly_warehouse) if self.skip_available_sub_assembly_item else None get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty, self.company, warehouse=warehouse) self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) sub_assembly_items_store.extend(bom_data) + if not sub_assembly_items_store and self.skip_available_sub_assembly_item: + message = ( + _( + "As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}." + ).format(self.sub_assembly_warehouse) + + "

" + ) + message += _( + "If you still want to proceed, please disable 'Skip Available Sub Assembly Items' checkbox." + ) + + frappe.msgprint(message, title=_("Note")) + if self.combine_sub_items: # Combine subassembly items sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store) @@ -849,15 +858,19 @@ class ProductionPlan(Document): def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): "Modify bom_data, set additional details." + is_group_warehouse = frappe.db.get_value("Warehouse", self.sub_assembly_warehouse, "is_group") + for data in bom_data: data.qty = data.stock_qty data.production_plan_item = row.name - data.fg_warehouse = self.sub_assembly_warehouse or row.warehouse data.schedule_date = row.planned_start_date data.type_of_manufacturing = manufacturing_type or ( "Subcontract" if data.is_sub_contracted_item else "In House" ) + if not is_group_warehouse: + data.fg_warehouse = self.sub_assembly_warehouse + def set_default_supplier_for_subcontracting_order(self): items = [ d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract" @@ -1401,7 +1414,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d so_item_details = frappe._dict() sub_assembly_items = {} - if doc.get("skip_available_sub_assembly_item"): + if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"): for d in doc.get("sub_assembly_items"): sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty")) @@ -1430,19 +1443,17 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx"))) if bom_no: - if ( - data.get("include_exploded_items") - and doc.get("sub_assembly_items") - and doc.get("skip_available_sub_assembly_item") - ): - item_details = get_raw_materials_of_sub_assembly_items( - item_details, - company, - bom_no, - include_non_stock_items, - sub_assembly_items, - planned_qty=planned_qty, - ) + if data.get("include_exploded_items") and doc.get("skip_available_sub_assembly_item"): + item_details = {} + if doc.get("sub_assembly_items"): + item_details = get_raw_materials_of_sub_assembly_items( + item_details, + company, + bom_no, + include_non_stock_items, + sub_assembly_items, + planned_qty=planned_qty, + ) elif data.get("include_exploded_items") and include_subcontracted_items: # fetch exploded items from BOM @@ -1615,34 +1626,37 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, company, warehouse= stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) if warehouse: - bin_dict = get_bin_details(d, company, for_warehouse=warehouse) + bin_details = get_bin_details(d, company, for_warehouse=warehouse) - if bin_dict and bin_dict[0].projected_qty > 0: - if bin_dict[0].projected_qty > stock_qty: - continue - else: - stock_qty = stock_qty - bin_dict[0].projected_qty + for _bin_dict in bin_details: + if _bin_dict.projected_qty > 0: + if _bin_dict.projected_qty > stock_qty: + stock_qty = 0 + continue + else: + stock_qty = stock_qty - _bin_dict.projected_qty - bom_data.append( - frappe._dict( - { - "parent_item_code": parent_item_code, - "description": d.description, - "production_item": d.item_code, - "item_name": d.item_name, - "stock_uom": d.stock_uom, - "uom": d.stock_uom, - "bom_no": d.value, - "is_sub_contracted_item": d.is_sub_contracted_item, - "bom_level": indent, - "indent": indent, - "stock_qty": stock_qty, - } + if stock_qty > 0: + bom_data.append( + frappe._dict( + { + "parent_item_code": parent_item_code, + "description": d.description, + "production_item": d.item_code, + "item_name": d.item_name, + "stock_uom": d.stock_uom, + "uom": d.stock_uom, + "bom_no": d.value, + "is_sub_contracted_item": d.is_sub_contracted_item, + "bom_level": indent, + "indent": indent, + "stock_qty": stock_qty, + } + ) ) - ) - if d.value: - get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1) + if d.value: + get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1) def set_default_warehouses(row, default_warehouses): diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 2b9751926a2..7e33eb80f7d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1194,6 +1194,7 @@ class TestProductionPlan(FrappeTestCase): ignore_existing_ordered_qty=1, do_not_submit=1, skip_available_sub_assembly_item=1, + sub_assembly_warehouse="_Test Warehouse - _TC", warehouse="_Test Warehouse - _TC", ) @@ -1221,6 +1222,35 @@ class TestProductionPlan(FrappeTestCase): if row.item_code == "SubAssembly2 For SUB Test": self.assertEqual(row.quantity, 10) + def test_sub_assembly_and_their_raw_materials_exists(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + bom_tree = { + "FG1 For SUB Test": { + "SAB1 For SUB Test": {"CP1 For SUB Test": {}}, + "SAB2 For SUB Test": {}, + } + } + + parent_bom = create_nested_bom(bom_tree, prefix="") + for item in ["SAB1 For SUB Test", "SAB2 For SUB Test"]: + make_stock_entry(item_code=item, qty=10, rate=100, target="_Test Warehouse - _TC") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=10, + ignore_existing_ordered_qty=1, + do_not_submit=1, + skip_available_sub_assembly_item=1, + warehouse="_Test Warehouse - _TC", + ) + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + self.assertFalse(items) + def test_transfer_and_purchase_mrp_for_purchase_uom(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -1298,6 +1328,7 @@ class TestProductionPlan(FrappeTestCase): ignore_existing_ordered_qty=1, do_not_submit=1, skip_available_sub_assembly_item=1, + sub_assembly_warehouse="_Test Warehouse - _TC", warehouse="_Test Warehouse - _TC", ) @@ -1550,6 +1581,48 @@ class TestProductionPlan(FrappeTestCase): for row in work_orders: self.assertEqual(row.qty, wo_qty[row.name]) + def test_parent_warehouse_for_sub_assembly_items(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + parent_warehouse = "_Test Warehouse Group - _TC" + sub_warehouse = create_warehouse("Sub Warehouse", company="_Test Company") + + fg_item = make_item(properties={"is_stock_item": 1}).name + sf_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1}).name + + bom_tree = {fg_item: {sf_item: {rm_item: {}}}} + create_nested_bom(bom_tree, prefix="") + + pln = create_production_plan( + item_code=fg_item, + planned_qty=10, + warehouse="_Test Warehouse - _TC", + sub_assembly_warehouse=parent_warehouse, + skip_available_sub_assembly_item=1, + do_not_submit=1, + skip_getting_mr_items=1, + ) + + pln.get_sub_assembly_items() + + for row in pln.sub_assembly_items: + self.assertFalse(row.fg_warehouse) + self.assertEqual(row.production_item, sf_item) + self.assertEqual(row.qty, 10.0) + + make_stock_entry(item_code=sf_item, qty=5, target=sub_warehouse, rate=100) + + pln.sub_assembly_items = [] + pln.get_sub_assembly_items() + + self.assertEqual(pln.sub_assembly_warehouse, parent_warehouse) + for row in pln.sub_assembly_items: + self.assertFalse(row.fg_warehouse) + self.assertEqual(row.production_item, sf_item) + self.assertEqual(row.qty, 5.0) + 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 0688278e091..78a389760a7 100644 --- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json +++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json @@ -11,6 +11,7 @@ "bom_no", "column_break_6", "planned_qty", + "stock_uom", "warehouse", "planned_start_date", "section_break_9", @@ -18,7 +19,6 @@ "ordered_qty", "column_break_17", "description", - "stock_uom", "produced_qty", "reference_section", "sales_order", @@ -65,6 +65,7 @@ "width": "100px" }, { + "columns": 1, "fieldname": "planned_qty", "fieldtype": "Float", "in_list_view": 1, @@ -80,6 +81,7 @@ "fieldtype": "Column Break" }, { + "columns": 2, "fieldname": "warehouse", "fieldtype": "Link", "in_list_view": 1, @@ -141,8 +143,10 @@ "width": "200px" }, { + "columns": 1, "fieldname": "stock_uom", "fieldtype": "Link", + "in_list_view": 1, "label": "UOM", "oldfieldname": "stock_uom", "oldfieldtype": "Data", @@ -216,7 +220,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-11-25 14:15:40.061514", + "modified": "2024-02-27 13:24:43.571844", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item", diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index aff740b732e..7965965d2b6 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -101,7 +101,6 @@ "columns": 1, "fieldname": "bom_level", "fieldtype": "Int", - "in_list_view": 1, "label": "Level (BOM)", "read_only": 1 }, @@ -149,8 +148,10 @@ "label": "Indent" }, { + "columns": 2, "fieldname": "fg_warehouse", "fieldtype": "Link", + "in_list_view": 1, "label": "Target Warehouse", "options": "Warehouse" }, @@ -170,6 +171,7 @@ "options": "Supplier" }, { + "columns": 1, "fieldname": "schedule_date", "fieldtype": "Datetime", "in_list_view": 1, @@ -207,7 +209,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-11-03 13:33:42.959387", + "modified": "2024-02-27 13:45:17.422435", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 74c2ece6e7e..8e24d4cd59a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1778,6 +1778,113 @@ class TestWorkOrder(FrappeTestCase): "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 0 ) + def test_capcity_planning_for_workstation(self): + frappe.db.set_single_value( + "Manufacturing Settings", + { + "disable_capacity_planning": 0, + "capacity_planning_for_days": 1, + "mins_between_operations": 10, + }, + ) + + properties = {"is_stock_item": 1, "valuation_rate": 100} + fg_item = make_item("Test FG Item For Capacity Planning", properties).name + + rm_item = make_item("Test RM Item For Capacity Planning", properties).name + + workstation = "Test Workstation For Capacity Planning" + if not frappe.db.exists("Workstation", workstation): + make_workstation(workstation=workstation, production_capacity=1) + + operation = "Test Operation For Capacity Planning" + if not frappe.db.exists("Operation", operation): + make_operation(operation=operation, workstation=workstation) + + bom_doc = make_bom( + item=fg_item, + source_warehouse="Stores - _TC", + raw_materials=[rm_item], + with_operations=1, + do_not_submit=True, + ) + + bom_doc.append( + "operations", + {"operation": operation, "time_in_mins": 1420, "hour_rate": 100, "workstation": workstation}, + ) + bom_doc.submit() + + # 1st Work Order, + # Capacity to run parallel the operation 'Test Operation For Capacity Planning' is 2 + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + wo_doc.submit() + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name}, + ) + + self.assertEqual(len(job_cards), 1) + + # 2nd Work Order, + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + wo_doc.submit() + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name}, + ) + + self.assertEqual(len(job_cards), 1) + + # 3rd Work Order, capacity is full + wo_doc = make_wo_order_test_record( + production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1 + ) + + self.assertRaises(CapacityError, wo_doc.submit) + + frappe.db.set_single_value( + "Manufacturing Settings", {"disable_capacity_planning": 1, "mins_between_operations": 0} + ) + + +def make_operation(**kwargs): + kwargs = frappe._dict(kwargs) + + operation_doc = frappe.get_doc( + { + "doctype": "Operation", + "name": kwargs.operation, + "workstation": kwargs.workstation, + } + ) + operation_doc.insert() + + return operation_doc + + +def make_workstation(**kwargs): + kwargs = frappe._dict(kwargs) + + workstation_doc = frappe.get_doc( + { + "doctype": "Workstation", + "workstation_name": kwargs.workstation, + "workstation_type": kwargs.workstation_type, + "production_capacity": kwargs.production_capacity or 0, + "hour_rate": kwargs.hour_rate or 100, + } + ) + workstation_doc.insert() + + return workstation_doc + def prepare_boms_for_sub_assembly_test(): if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f95b4f66a33..77a0c54c734 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -172,8 +172,12 @@ class WorkOrder(Document): def calculate_operating_cost(self): self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0 for d in self.get("operations"): - d.planned_operating_cost = flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0) - d.actual_operating_cost = flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0) + d.planned_operating_cost = flt( + flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost") + ) + d.actual_operating_cost = flt( + flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost") + ) self.planned_operating_cost += flt(d.planned_operating_cost) self.actual_operating_cost += flt(d.actual_operating_cost) @@ -489,7 +493,6 @@ class WorkOrder(Document): def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning): self.set_operation_start_end_time(index, row) - original_start_time = row.planned_start_time job_card_doc = create_job_card( self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning ) @@ -498,11 +501,15 @@ class WorkOrder(Document): row.planned_start_time = job_card_doc.time_logs[-1].from_time row.planned_end_time = job_card_doc.time_logs[-1].to_time - if date_diff(row.planned_start_time, original_start_time) > plan_days: + if date_diff(row.planned_end_time, self.planned_start_date) > plan_days: frappe.message_log.pop() frappe.throw( - _("Unable to find the time slot in the next {0} days for the operation {1}.").format( - plan_days, row.operation + _( + "Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}." + ).format( + plan_days, + row.operation, + get_link_to_form("Manufacturing Settings", "Manufacturing Settings"), ), CapacityError, ) diff --git a/erpnext/manufacturing/report/completed_work_orders/completed_work_orders.json b/erpnext/manufacturing/report/completed_work_orders/completed_work_orders.json index be50e93f1ba..7925b8a8ab8 100644 --- a/erpnext/manufacturing/report/completed_work_orders/completed_work_orders.json +++ b/erpnext/manufacturing/report/completed_work_orders/completed_work_orders.json @@ -1,25 +1,28 @@ { - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2013-08-12 12:44:27", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 3, - "is_standard": "Yes", - "modified": "2018-02-13 04:58:51.549413", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Completed Work Orders", - "owner": "Administrator", - "query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) = `tabWork Order`.qty", - "ref_doctype": "Work Order", - "report_name": "Completed Work Orders", - "report_type": "Query Report", + "add_total_row": 0, + "columns": [], + "creation": "2013-08-12 12:44:27", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 3, + "is_standard": "Yes", + "letterhead": null, + "modified": "2024-02-21 14:35:14.301848", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Completed Work Orders", + "owner": "Administrator", + "prepared_report": 0, + "query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) >= `tabWork Order`.qty", + "ref_doctype": "Work Order", + "report_name": "Completed Work Orders", + "report_type": "Query Report", "roles": [ { "role": "Manufacturing User" - }, + }, { "role": "Stock User" } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6b7b13ff46e..90650a640ad 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -353,8 +353,10 @@ erpnext.patches.v14_0.update_zero_asset_quantity_field execute:frappe.db.set_single_value("Buying Settings", "project_update_frequency", "Each Transaction") erpnext.patches.v14_0.clear_reconciliation_values_from_singles erpnext.patches.v14_0.update_total_asset_cost_field +erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20 erpnext.patches.v14_0.set_maintain_stock_for_bom_item execute:frappe.db.set_single_value('E Commerce Settings', 'show_actual_qty', 1) +erpnext.patches.v14_0.delete_orphaned_asset_movement_item_records diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_in_reconciliation_tool.py b/erpnext/patches/v14_0/create_accounting_dimensions_in_reconciliation_tool.py new file mode 100644 index 00000000000..4466eaace8d --- /dev/null +++ b/erpnext/patches/v14_0/create_accounting_dimensions_in_reconciliation_tool.py @@ -0,0 +1,8 @@ +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + create_accounting_dimensions_for_doctype, +) + + +def execute(): + create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation") + create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation Allocation") diff --git a/erpnext/patches/v14_0/delete_orphaned_asset_movement_item_records.py b/erpnext/patches/v14_0/delete_orphaned_asset_movement_item_records.py new file mode 100644 index 00000000000..a1d7dc9b3d4 --- /dev/null +++ b/erpnext/patches/v14_0/delete_orphaned_asset_movement_item_records.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + # nosemgrep + frappe.db.sql( + """ + DELETE FROM `tabAsset Movement Item` + WHERE parent NOT IN (SELECT name FROM `tabAsset Movement`) + """ + ) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 9e52befd22e..4db647eb3e2 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -77,7 +77,7 @@ class Timesheet(Document): def set_status(self): self.status = {"0": "Draft", "1": "Submitted", "2": "Cancelled"}[str(self.docstatus or 0)] - if self.per_billed == 100: + if flt(self.per_billed, self.precision("per_billed")) >= 100.0: self.status = "Billed" if self.sales_invoice: diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index d5bc7647647..b4b85c4a2bb 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -140,7 +140,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } if(this.frm.fields_dict["items"]) { - this["items_remove"] = this.calculate_net_weight; + this["items_remove"] = this.process_item_removal; } if(this.frm.fields_dict["recurring_print_format"]) { @@ -1192,6 +1192,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + process_item_removal() { + this.frm.trigger("calculate_taxes_and_totals"); + this.frm.trigger("calculate_net_weight"); + } + calculate_net_weight(){ /* Calculate Total Net Weight then further applied shipping rule to calculate shipping charges.*/ var me = this; diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 4c76e2a869e..27c7444daf4 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -40,7 +40,10 @@ $.extend(erpnext, { is_perpetual_inventory_enabled: function(company) { if(company) { - return frappe.get_doc(":Company", company).enable_perpetual_inventory + let company_local = locals[":Company"] && locals[":Company"][company]; + if(company_local) { + return cint(company_local.enable_perpetual_inventory); + } } }, diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 7a601a78876..7e91e6e6599 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -297,11 +297,35 @@ class TestCustomer(FrappeTestCase): if credit_limit > outstanding_amt: set_credit_limit("_Test Customer", "_Test Company", credit_limit) - # Makes Sales invoice from Sales Order - so.save(ignore_permissions=True) - si = make_sales_invoice(so.name) - si.save(ignore_permissions=True) - self.assertRaises(frappe.ValidationError, make_sales_order) + def test_customer_credit_limit_after_submit(self): + from erpnext.controllers.accounts_controller import update_child_qty_rate + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + outstanding_amt = self.get_customer_outstanding_amount() + credit_limit = get_credit_limit("_Test Customer", "_Test Company") + + if outstanding_amt <= 0.0: + item_qty = int((abs(outstanding_amt) + 200) / 100) + make_sales_order(qty=item_qty) + + if credit_limit <= 0.0: + set_credit_limit("_Test Customer", "_Test Company", outstanding_amt + 100) + + so = make_sales_order(rate=100, qty=1) + # Update qty in submitted Sales Order to trigger Credit Limit validation + fields = ["name", "item_code", "delivery_date", "conversion_factor", "qty", "rate", "uom", "idx"] + modified_item = frappe._dict() + for x in fields: + modified_item[x] = so.items[0].get(x) + modified_item["docname"] = so.items[0].name + modified_item["qty"] = 2 + self.assertRaises( + frappe.ValidationError, + update_child_qty_rate, + so.doctype, + frappe.json.dumps([modified_item]), + so.name, + ) def test_customer_credit_limit_on_change(self): outstanding_amt = self.get_customer_outstanding_amount() diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index bec24fe5e99..82680368181 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -362,6 +362,9 @@ class SalesOrder(SellingController): def on_update(self): pass + def on_update_after_submit(self): + self.check_credit_limit() + def before_update_after_submit(self): self.validate_po() self.validate_drop_ship() diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.js b/erpnext/selling/page/sales_funnel/sales_funnel.js index e3d0a55c3a0..c37c7266362 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.js +++ b/erpnext/selling/page/sales_funnel/sales_funnel.js @@ -229,7 +229,7 @@ erpnext.SalesFunnel = class SalesFunnel { context.fill(); // draw text - context.fillStyle = "black"; + context.fillStyle = ""; context.textBaseline = "middle"; context.font = "1.1em sans-serif"; context.fillText(__(title), width + 20, y_mid); diff --git a/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py b/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py index 7d28f2b90d2..f2f1e4cfbaa 100644 --- a/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py +++ b/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py @@ -206,42 +206,36 @@ def prepare_data( def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_field): fiscal_year = get_fiscal_year(fiscal_year=filters.get("fiscal_year"), as_dict=1) - dates = [fiscal_year.year_start_date, fiscal_year.year_end_date] - select_field = "`tab{0}`.{1}".format(filters.get("doctype"), sales_field) - child_table = "`tab{0}`".format(filters.get("doctype") + " Item") + parent_doc = frappe.qb.DocType(filters.get("doctype")) + child_doc = frappe.qb.DocType(filters.get("doctype") + " Item") + sales_team = frappe.qb.DocType("Sales Team") + + query = ( + frappe.qb.from_(parent_doc) + .inner_join(child_doc) + .on(child_doc.parent == parent_doc.name) + .inner_join(sales_team) + .on(sales_team.parent == parent_doc.name) + .select( + child_doc.item_group, + (child_doc.stock_qty * sales_team.allocated_percentage / 100).as_("stock_qty"), + (child_doc.base_net_amount * sales_team.allocated_percentage / 100).as_("base_net_amount"), + sales_team.sales_person, + parent_doc[date_field], + ) + .where( + (parent_doc.docstatus == 1) + & (parent_doc[date_field].between(fiscal_year.year_start_date, fiscal_year.year_end_date)) + ) + ) if sales_field == "sales_person": - select_field = "`tabSales Team`.sales_person" - child_table = "`tab{0}`, `tabSales Team`".format(filters.get("doctype") + " Item") - cond = """`tabSales Team`.parent = `tab{0}`.name and - `tabSales Team`.sales_person in ({1}) """.format( - filters.get("doctype"), ",".join(["%s"] * len(sales_users_or_territory_data)) - ) + query = query.where(sales_team.sales_person.isin(sales_users_or_territory_data)) else: - cond = "`tab{0}`.{1} in ({2})".format( - filters.get("doctype"), sales_field, ",".join(["%s"] * len(sales_users_or_territory_data)) - ) + query = query.where(parent_doc[sales_field].isin(sales_users_or_territory_data)) - return frappe.db.sql( - """ SELECT `tab{child_doc}`.item_group, - `tab{child_doc}`.stock_qty, `tab{child_doc}`.base_net_amount, - {select_field}, `tab{parent_doc}`.{date_field} - FROM `tab{parent_doc}`, {child_table} - WHERE - `tab{child_doc}`.parent = `tab{parent_doc}`.name - and `tab{parent_doc}`.docstatus = 1 and {cond} - and `tab{parent_doc}`.{date_field} between %s and %s""".format( - cond=cond, - date_field=date_field, - select_field=select_field, - child_table=child_table, - parent_doc=filters.get("doctype"), - child_doc=filters.get("doctype") + " Item", - ), - tuple(sales_users_or_territory_data + dates), - as_dict=1, - ) + return query.run(as_dict=True) def get_parents_data(filters, partner_doctype): diff --git a/erpnext/selling/report/sales_person_target_variance_based_on_item_group/test_sales_person_target_variance_based_on_item_group.py b/erpnext/selling/report/sales_person_target_variance_based_on_item_group/test_sales_person_target_variance_based_on_item_group.py new file mode 100644 index 00000000000..4ae5d2bee88 --- /dev/null +++ b/erpnext/selling/report/sales_person_target_variance_based_on_item_group/test_sales_person_target_variance_based_on_item_group.py @@ -0,0 +1,84 @@ +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import flt, nowdate + +from erpnext.accounts.utils import get_fiscal_year +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.sales_person_target_variance_based_on_item_group.sales_person_target_variance_based_on_item_group import ( + execute, +) + + +class TestSalesPersonTargetVarianceBasedOnItemGroup(FrappeTestCase): + def setUp(self): + self.fiscal_year = get_fiscal_year(nowdate())[0] + + def tearDown(self): + frappe.db.rollback() + + def test_achieved_target_and_variance(self): + # Create a Target Distribution + distribution = frappe.new_doc("Monthly Distribution") + distribution.distribution_id = "Target Report Distribution" + distribution.fiscal_year = self.fiscal_year + distribution.get_months() + distribution.insert() + + # Create sales people with targets + person_1 = create_sales_person_with_target("Sales Person 1", self.fiscal_year, distribution.name) + person_2 = create_sales_person_with_target("Sales Person 2", self.fiscal_year, distribution.name) + + # Create a Sales Order with 50-50 contribution + so = make_sales_order( + rate=1000, + qty=20, + do_not_submit=True, + ) + so.set( + "sales_team", + [ + { + "sales_person": person_1.name, + "allocated_percentage": 50, + "allocated_amount": 10000, + }, + { + "sales_person": person_2.name, + "allocated_percentage": 50, + "allocated_amount": 10000, + }, + ], + ) + so.submit() + + # Check Achieved Target and Variance + result = execute( + frappe._dict( + { + "fiscal_year": self.fiscal_year, + "doctype": "Sales Order", + "period": "Yearly", + "target_on": "Quantity", + } + ) + )[1] + row = frappe._dict(result[0]) + self.assertSequenceEqual( + [flt(value, 2) for value in (row.total_target, row.total_achieved, row.total_variance)], + [50, 10, -40], + ) + + +def create_sales_person_with_target(sales_person_name, fiscal_year, distribution_id): + sales_person = frappe.new_doc("Sales Person") + sales_person.sales_person_name = sales_person_name + sales_person.append( + "targets", + { + "fiscal_year": fiscal_year, + "target_qty": 50, + "target_amount": 30000, + "distribution_id": distribution_id, + }, + ) + return sales_person.insert() diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index 9f3ba0da8bd..847488f6fbf 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -36,6 +36,7 @@ def execute(filters=None): d.base_net_amount, d.sales_person, d.allocated_percentage, + (d.stock_qty * d.allocated_percentage / 100), d.contribution_amt, company_currency, ] @@ -103,7 +104,7 @@ def get_columns(filters): "fieldtype": "Link", "width": 140, }, - {"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140}, + {"label": _("SO Total Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140}, { "label": _("Amount"), "options": "currency", @@ -119,6 +120,12 @@ def get_columns(filters): "width": 140, }, {"label": _("Contribution %"), "fieldname": "contribution", "fieldtype": "Float", "width": 140}, + { + "label": _("Contribution Qty"), + "fieldname": "contribution_qty", + "fieldtype": "Float", + "width": 140, + }, { "label": _("Contribution Amount"), "options": "currency", diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index c5bc601e391..20bfbef8f49 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -199,6 +199,7 @@ frappe.ui.form.on('Material Request', { get_item_data: function(frm, item, overwrite_warehouse=false) { if (item && !item.item_code) { return; } + frappe.call({ method: "erpnext.stock.get_item_details.get_item_details", args: { @@ -225,20 +226,22 @@ frappe.ui.form.on('Material Request', { }, callback: function(r) { const d = item; - const qty_fields = ['actual_qty', 'projected_qty', 'min_order_qty']; + const allow_to_change_fields = ['actual_qty', 'projected_qty', 'min_order_qty', 'item_name', 'description', 'stock_uom', 'uom', 'conversion_factor', 'stock_qty']; if(!r.exc) { $.each(r.message, function(key, value) { - if(!d[key] || qty_fields.includes(key)) { + if(!d[key] || allow_to_change_fields.includes(key)) { d[key] = value; } }); if (d.price_list_rate != r.message.price_list_rate) { + d.rate = 0.0; d.price_list_rate = r.message.price_list_rate; - frappe.model.set_value(d.doctype, d.name, "rate", d.price_list_rate); } + + refresh_field("items"); } } }); @@ -435,7 +438,7 @@ frappe.ui.form.on("Material Request Item", { frm.events.get_item_data(frm, item, false); }, - rate: function(frm, doctype, name) { + rate(frm, doctype, name) { const item = locals[doctype][name]; item.amount = flt(item.qty) * flt(item.rate); frappe.model.set_value(doctype, name, "amount", item.amount); diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index d8141f93e68..7defbc5bcdf 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2189,6 +2189,41 @@ class TestPurchaseReceipt(FrappeTestCase): pr.cancel() self.assertTrue(frappe.db.exists("Batch", batch_no)) + def test_pr_billed_amount_against_return_entry(self): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + make_purchase_invoice as make_pi_from_pr, + ) + + # Create a Purchase Receipt and Fully Bill it + pr = make_purchase_receipt(qty=10) + pi = make_pi_from_pr(pr.name) + pi.insert() + pi.submit() + + # Debit Note - 50% Qty & enable updating PR billed amount + pi_return = make_debit_note(pi.name) + pi_return.items[0].qty = -5 + pi_return.update_billed_amount_in_purchase_receipt = 1 + pi_return.submit() + + # Check if the billed amount reduced + pr.reload() + self.assertEqual(pr.per_billed, 50) + + pi_return.reload() + pi_return.cancel() + + # Debit Note - 50% Qty & disable updating PR billed amount + pi_return = make_debit_note(pi.name) + pi_return.items[0].qty = -5 + pi_return.update_billed_amount_in_purchase_receipt = 0 + pi_return.submit() + + # Check if the billed amount stayed the same + pr.reload() + self.assertEqual(pr.per_billed, 100) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier 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 6ffb34dc135..5d087a46242 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -226,6 +226,7 @@ def on_doctype_update(): def repost(doc): try: + frappe.flags.through_repost_item_valuation = True if not frappe.db.exists("Repost Item Valuation", doc.name): return diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 44c2d85b4cc..878af0e22ca 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -702,10 +702,7 @@ class StockReconciliation(StockController): if allow_negative_stock: return True - if any( - (d.batch_no and flt(d.qty) == flt(d.current_qty)) - for d in self.items - ): + if any((d.batch_no and flt(d.qty) == flt(d.current_qty)) for d in self.items): allow_negative_stock = True return allow_negative_stock diff --git a/pyproject.toml b/pyproject.toml index 7d1e7af50e4..7f28903f120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,6 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true indent = "\t" + +[tool.bench.frappe-dependencies] +frappe = ">=14.0.0,<15.0.0"