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"