diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index 303633ac4bb..4294c4462b1 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
- "customer_group": "All Customer Groups",
+ "customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Poore Simon's",
}
@@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
- "customer_group": "All Customer Groups",
+ "customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Fayva",
}
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
index 378fbded863..bfab823f495 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
@@ -209,7 +209,7 @@ def make_customer(customer=None):
{
"doctype": "Customer",
"customer_name": customer_name,
- "customer_group": "All Customer Groups",
+ "customer_group": "Individual",
"customer_type": "Company",
"territory": "All Territories",
}
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 60c8e47f8f0..42fdc5124bf 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -839,7 +839,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
- if (!frm.doc.received_amount) {
+ if (frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
@@ -860,7 +860,7 @@ frappe.ui.form.on("Payment Entry", {
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
- if (!frm.doc.paid_amount) {
+ if (frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) {
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index da6c2eefae4..bec4a8391e3 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -2043,6 +2043,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.customer_name = name
customer.default_currency = currency
customer.type = "Individual"
+ customer.customer_group = "Individual"
customer.save()
customer = customer.name
return customer
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
index 9a33a7ccf6d..dd3a936f220 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
+++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py
@@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
+ customer.customer_group = "Individual"
customer.save()
self.customer = customer.name
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 59c385855fa..b7d8fb44853 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
+ customer.customer_group = "Individual"
if currency:
customer.default_currency = currency
diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js
index 43261e4080a..88e5b5216b5 100644
--- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js
+++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js
@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
selling: function (frm) {
frm.trigger("set_options_for_applicable_for");
+ frm.toggle_enable("buying", !frm.doc.selling);
},
buying: function (frm) {
frm.trigger("set_options_for_applicable_for");
+ frm.toggle_enable("selling", !frm.doc.buying);
},
set_options_for_applicable_for: function (frm) {
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index ea513e0cfd7..b80eadc7ae0 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
+ adjust_incoming_rate = frappe.db.get_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
+ )
+
for item in self.get("items"):
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
if item.item_code:
@@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController):
)
# check if the exchange rate has changed
- if item.get("purchase_receipt") and self.auto_accounting_for_stock:
+ if (
+ not adjust_incoming_rate
+ and item.get("purchase_receipt")
+ and self.auto_accounting_for_stock
+ ):
if (
exchange_rate_map[item.purchase_receipt]
and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
@@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController):
item=item,
)
)
+
if (
self.auto_accounting_for_stock
and self.is_opening == "No"
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index f8e12eda182..bccd29c822a 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
+ original_value = frappe.db.get_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
+ )
+
+ frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
+
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
@@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
+
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
+ frappe.db.set_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
+ )
+
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice,
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index f207b2079ab..a5abf3dd32f 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -777,8 +777,7 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "eval:doc.total_billing_amount > 0",
- "depends_on": "eval:!doc.is_return",
+ "collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "time_sheet_list",
"fieldtype": "Section Break",
"hide_border": 1,
@@ -792,7 +791,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Time Sheets",
- "no_copy": 1,
"options": "Sales Invoice Timesheet",
"print_hide": 1
},
@@ -2112,7 +2110,7 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
+ "depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "section_break_104",
"fieldtype": "Section Break"
},
@@ -2200,7 +2198,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2026-02-05 20:43:44.732805",
+ "modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 6bc24633ca9..3e16c503e69 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -323,10 +323,22 @@ class SalesInvoice(SellingController):
)
self.set_against_income_account()
- self.validate_time_sheets_are_submitted()
+
+ if self.is_return and not self.return_against and self.timesheets:
+ frappe.throw(_("Direct return is not allowed for Timesheet."))
+
+ if not self.is_return:
+ self.validate_time_sheets_are_submitted()
+
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
- if self.is_return:
- self.timesheets = []
+
+ if self.is_return and self.return_against:
+ for row in self.timesheets:
+ if row.billing_hours:
+ row.billing_hours = -abs(row.billing_hours)
+ if row.billing_amount:
+ row.billing_amount = -abs(row.billing_amount)
+
self.update_packing_list()
self.set_billing_hours_and_amount()
self.update_timesheet_billing_for_project()
@@ -494,7 +506,7 @@ class SalesInvoice(SellingController):
if not cint(self.is_pos) == 1 and not self.is_return:
self.update_against_document_in_jv()
- self.update_time_sheet(self.name)
+ self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
@@ -550,7 +562,7 @@ class SalesInvoice(SellingController):
self.check_if_consolidated_invoice()
super().before_cancel()
- self.update_time_sheet(None)
+ self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -735,8 +747,20 @@ class SalesInvoice(SellingController):
for data in timesheet.time_logs:
if (
(self.project and args.timesheet_detail == data.name)
- or (not self.project and not data.sales_invoice)
- or (not sales_invoice and data.sales_invoice == self.name)
+ or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
+ or (
+ not sales_invoice
+ and data.sales_invoice == self.name
+ and args.timesheet_detail == data.name
+ )
+ or (
+ self.is_return
+ and self.return_against
+ and data.sales_invoice
+ and data.sales_invoice == self.return_against
+ and not sales_invoice
+ and args.timesheet_detail == data.name
+ )
):
data.sales_invoice = sales_invoice
@@ -776,11 +800,25 @@ class SalesInvoice(SellingController):
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
def validate_time_sheets_are_submitted(self):
+ # Note: This validation is skipped for return invoices
+ # to allow returns to reference already-billed timesheet details
for data in self.timesheets:
+ # Handle invoice duplication
+ if data.time_sheet and data.timesheet_detail:
+ if sales_invoice := frappe.db.get_value(
+ "Timesheet Detail", data.timesheet_detail, "sales_invoice"
+ ):
+ frappe.throw(
+ _("Row {0}: Sales Invoice {1} is already created for {2}").format(
+ data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
+ )
+ )
if data.time_sheet:
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
- if status not in ["Submitted", "Payslip"]:
- frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
+ if status not in ["Submitted", "Payslip", "Partially Billed"]:
+ frappe.throw(
+ _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
+ )
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
@@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController):
timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self):
- if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
+ if (
+ not self.is_return
+ and not self.timesheets
+ and self.project
+ and self.is_auto_fetch_timesheet_enabled()
+ ):
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet()
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index c515322348a..a8fb4ab93a0 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase):
calculate_depreciation=1,
submit=1,
)
- post_depreciation_entries()
+ post_depreciation_entries(date="2025-04-01")
si = create_sales_invoice(
item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01")
diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
index 69b7c129f09..f959054f4a2 100644
--- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
+++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
@@ -52,7 +52,6 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Timesheet Detail",
- "no_copy": 1,
"print_hide": 1,
"read_only": 1
},
@@ -117,7 +116,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-10-02 03:48:44.979777",
+ "modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Timesheet",
diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py
index 1d906fb9276..aba51ac5c6a 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -629,18 +629,21 @@ def create_parties():
customer.customer_name = "_Test Subscription Customer"
customer.default_currency = "USD"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
+ customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
customer = frappe.new_doc("Customer")
customer.customer_name = "Test Subscription Customer Multi Currency"
customer.default_currency = "USD"
+ customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer John Doe"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"})
+ customer.customer_group = "Individual"
customer.insert()
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 4dfcd3bf259..88a3b818196 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
+ "customer_group": "Individual",
}
)
.insert()
@@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
+ "customer_group": "Individual",
}
)
.insert()
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index 35f24df015f..9a0a9cc5174 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
+ customer.customer_group = "Individual"
customer.save()
self.customer = customer.name
diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py
index 3cad657553e..2bce9b46835 100644
--- a/erpnext/accounts/test/accounts_mixin.py
+++ b/erpnext/accounts/test/accounts_mixin.py
@@ -12,6 +12,7 @@ class AccountsTestMixin:
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
+ customer.customer_group = "Individual"
if currency:
customer.default_currency = currency
@@ -36,6 +37,7 @@ class AccountsTestMixin:
"account": default_account,
},
)
+ customer.customer_group = "Individual"
customer.save()
self.customer = customer_name
diff --git a/erpnext/accounts/test_party.py b/erpnext/accounts/test_party.py
index 9d3de5e8282..8b4a52603bf 100644
--- a/erpnext/accounts/test_party.py
+++ b/erpnext/accounts/test_party.py
@@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list
class PartyTestCase(FrappeTestCase):
def test_get_default_price_list_should_return_none_for_invalid_group(self):
customer = frappe.get_doc(
- {
- "doctype": "Customer",
- "customer_name": "test customer",
- }
+ {"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"}
).insert(ignore_permissions=True, ignore_mandatory=True)
- customer.customer_group = None
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index ae7898f9a07..c4394c066e0 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase):
# ordered qty should decrease (back to initial) on row deletion
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
+ def test_discount_amount_partial_purchase_receipt(self):
+ po = create_purchase_order(qty=4, rate=100, do_not_save=1)
+ po.apply_discount_on = "Grand Total"
+ po.discount_amount = 120
+ po.save()
+ po.submit()
+
+ self.assertEqual(po.grand_total, 280)
+
+ pr1 = make_purchase_receipt(po.name)
+ pr1.items[0].qty = 3
+ pr1.save()
+ pr1.submit()
+
+ self.assertEqual(pr1.discount_amount, 120)
+ self.assertEqual(pr1.grand_total, 180)
+
+ pr2 = make_purchase_receipt(po.name)
+ pr2.save()
+ pr2.submit()
+
+ self.assertEqual(pr2.discount_amount, 0)
+ self.assertEqual(pr2.grand_total, 100)
+
def test_update_child_perm(self):
po = create_purchase_order(item_code="_Test Item", qty=4)
diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py
index e4475c7ee38..04983721a64 100644
--- a/erpnext/buying/doctype/supplier/test_supplier.py
+++ b/erpnext/buying/doctype/supplier/test_supplier.py
@@ -175,6 +175,15 @@ def create_supplier(**args):
if not args.without_supplier_group:
doc.supplier_group = args.supplier_group or "Services"
+ if args.get("party_account"):
+ doc.append(
+ "accounts",
+ {
+ "company": frappe.db.get_value("Account", args.get("party_account"), "company"),
+ "account": args.get("party_account"),
+ },
+ )
+
doc.insert()
return doc
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index dea76428d90..eedb0ce6eba 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -364,7 +364,7 @@ class BuyingController(SubcontractingController):
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
)
- net_rate = item.base_net_amount
+ net_rate = item.qty * item.base_net_rate
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 337ffbfeb0c..e75dd3dacd2 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -183,6 +183,9 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
+ bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
+ "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
+ )
for item in self.doc.items:
self.doc.round_floats_in(item)
@@ -238,7 +241,13 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
- item.amount = flt(item.rate * item.qty, item.precision("amount"))
+ qty = (
+ (item.qty + item.rejected_qty)
+ if bill_for_rejected_quantity_in_purchase_invoice
+ and self.doc.doctype == "Purchase Receipt"
+ else item.qty
+ )
+ item.amount = flt(item.rate * qty, item.precision("amount"))
item.net_amount = item.amount
@@ -370,9 +379,16 @@ class calculate_taxes_and_totals:
self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
+ bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
+ "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
+ )
for item in self._items:
self.doc.total += item.amount
- self.doc.total_qty += item.qty
+ self.doc.total_qty += (
+ (item.qty + item.rejected_qty)
+ if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
+ else item.qty
+ )
self.doc.base_total += item.base_amount
self.doc.net_total += item.net_amount
self.doc.base_net_total += item.base_net_amount
@@ -724,7 +740,8 @@ class calculate_taxes_and_totals:
discount_amount += total_return_discount
# validate that discount amount cannot exceed the total before discount
- if (
+ # only during save (i.e. when `_action` is set)
+ if self.doc.get("_action") and (
(grand_total >= 0 and discount_amount > grand_total)
or (grand_total < 0 and discount_amount < grand_total) # returns
):
diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py
index a120da6f852..3ecf3d8b0af 100644
--- a/erpnext/controllers/tests/test_accounts_controller.py
+++ b/erpnext/controllers/tests/test_accounts_controller.py
@@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
+ customer.customer_group = "Individual"
if currency:
customer.default_currency = currency
diff --git a/erpnext/controllers/tests/test_qty_based_taxes.py b/erpnext/controllers/tests/test_qty_based_taxes.py
index e7896b57f23..6233f6af9f4 100644
--- a/erpnext/controllers/tests/test_qty_based_taxes.py
+++ b/erpnext/controllers/tests/test_qty_based_taxes.py
@@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase):
{
"doctype": "Customer",
"customer_name": uuid4(),
- "customer_group": "All Customer Groups",
+ "customer_group": "Individual",
}
).insert()
self.supplier = frappe.get_doc(
diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py
index 6ec3ca4a6c1..f346946568e 100644
--- a/erpnext/crm/doctype/opportunity/test_opportunity.py
+++ b/erpnext/crm/doctype/opportunity/test_opportunity.py
@@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase):
self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email)
# create new customer and create new contact against 'new.opportunity@example.com'
- customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True)
+ customer = make_customer(opp_doc.party_name)
+ customer.customer_group = "Individual"
+ customer.insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",
diff --git a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py
index bf3f946d6ab..7622c38f94c 100644
--- a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py
+++ b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py
@@ -196,6 +196,7 @@ def create_customer():
if not doc:
doc = frappe.new_doc("Customer")
doc.customer_name = "_Test NC"
+ doc.customer_group = "Individual"
doc.insert()
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index 525b6aecee7..ed1628ded5f 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", {
reqd: 1,
default: 1,
onchange: () => {
+ if (!cur_dialog) return;
const { quantity, items: rm } = frm.doc;
const variant_items_map = rm.reduce((acc, item) => {
acc[item.item_code] = item.qty;
diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json
index 277200af310..64a1c870ce4 100644
--- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json
+++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json
@@ -15,6 +15,7 @@
"bom_section",
"update_bom_costs_automatically",
"column_break_lhyt",
+ "allow_editing_of_items_and_quantities_in_work_order",
"section_break_6",
"default_wip_warehouse",
"default_fg_warehouse",
@@ -243,13 +244,20 @@
"fieldname": "enforce_time_logs",
"fieldtype": "Check",
"label": "Enforce Time Logs"
+ },
+ {
+ "default": "0",
+ "description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
+ "fieldname": "allow_editing_of_items_and_quantities_in_work_order",
+ "fieldtype": "Check",
+ "label": "Allow Editing of Items and Quantities in Work Order"
}
],
"icon": "icon-wrench",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-05-16 11:23:16.916512",
+ "modified": "2025-11-07 14:52:56.241459",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",
diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py
index e9f011f78ba..44d42cccae0 100644
--- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py
+++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py
@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
from frappe.types import DF
add_corrective_operation_cost_in_finished_good_valuation: DF.Check
+ allow_editing_of_items_and_quantities_in_work_order: DF.Check
allow_overtime: DF.Check
allow_production_on_holidays: DF.Check
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index c23db9aa682..374caf369ea 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -4,7 +4,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
-from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
+from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
@@ -2395,7 +2395,7 @@ class TestWorkOrder(FrappeTestCase):
stock_entry.submit()
- def test_disassembly_order_with_qty_behavior(self):
+ def test_disassembly_order_with_qty_from_wo_behavior(self):
# Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
@@ -2435,27 +2435,9 @@ class TestWorkOrder(FrappeTestCase):
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
- # Simulate a disassembly stock entry
+ # Disassembly via WO required_items path (no source_stock_entry)
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
- stock_entry.append(
- "items",
- {
- "item_code": fg_item,
- "qty": disassemble_qty,
- "s_warehouse": wo.fg_warehouse,
- },
- )
-
- for bom_item in bom.items:
- stock_entry.append(
- "items",
- {
- "item_code": bom_item.item_code,
- "qty": (bom_item.qty / bom.quantity) * disassemble_qty,
- "t_warehouse": wo.source_warehouse,
- },
- )
wo.reload()
stock_entry.save()
@@ -2470,7 +2452,7 @@ class TestWorkOrder(FrappeTestCase):
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
)
- # Assert raw materials
+ # Assert raw materials - qty scaled from WO required_items
for item in stock_entry.items:
if item.item_code == fg_item:
continue
@@ -2494,10 +2476,35 @@ class TestWorkOrder(FrappeTestCase):
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
)
+ # Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
+ # (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
+ disassemble_qty_2 = 2
+ stock_entry_2 = frappe.get_doc(
+ make_stock_entry(
+ wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
+ )
+ )
+ stock_entry_2.save()
+ stock_entry_2.submit()
+
+ # All rows must trace back to se_for_manufacture
+ for item in stock_entry_2.items:
+ self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
+ self.assertTrue(item.ste_detail)
+
+ # RM qty scaled from the manufacture SE rows
+ rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
+ expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
+ self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
+
+ wo.reload()
+ self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
+
def test_disassembly_with_multiple_manufacture_entries(self):
"""
Test that disassembly does not create duplicate items when manufacturing
- is done in multiple batches (multiple manufacture stock entries).
+ is done in multiple batches (multiple manufacture stock entries), including
+ secondary/scrap items.
Scenario:
1. Create Work Order for 10 units
@@ -2506,11 +2513,17 @@ class TestWorkOrder(FrappeTestCase):
4. Create Disassembly for 4 units
5. Verify no duplicate items in the disassembly stock entry
"""
- # Create RM and FG item
+ # Create RM, scrap and FG item
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
+ scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
- bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
+ bom = make_bom(
+ item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True
+ )
+ # add scrap item
+ bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10})
+ bom.submit()
# Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
@@ -2585,7 +2598,7 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
- expected_items = 3 # FG item + 2 raw materials
+ expected_items = 4 # FG item + 2 raw materials + 1 scrap item
self.assertEqual(
len(stock_entry.items),
expected_items,
@@ -2596,6 +2609,16 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
+ # Scrap item: should be taken from scrap warehouse in disassembly
+ scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
+ self.assertIsNotNone(scrap_row)
+ self.assertEqual(scrap_row.is_scrap_item, 1)
+ self.assertTrue(scrap_row.s_warehouse)
+ self.assertFalse(scrap_row.t_warehouse)
+ self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
+ # BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40
+ self.assertEqual(scrap_row.qty, 40)
+
# RM quantities
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
@@ -2607,19 +2630,57 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Raw material {bom_item.item_code} qty mismatch",
)
+ # -- BOM-path disassembly (no source_stock_entry, no work_order) --
+
+ make_stock_entry_test_record(
+ item_code=scrap_item,
+ purpose="Material Receipt",
+ target=wo.fg_warehouse,
+ qty=50,
+ basic_rate=10,
+ )
+
+ bom_disassemble_qty = 2
+ bom_se = frappe.get_doc(
+ {
+ "doctype": "Stock Entry",
+ "stock_entry_type": "Disassemble",
+ "purpose": "Disassemble",
+ "from_bom": 1,
+ "bom_no": bom.name,
+ "fg_completed_qty": bom_disassemble_qty,
+ "from_warehouse": wo.fg_warehouse,
+ "to_warehouse": wo.wip_warehouse,
+ "company": wo.company,
+ "posting_date": nowdate(),
+ "posting_time": nowtime(),
+ }
+ )
+ bom_se.get_items()
+ bom_se.save()
+ bom_se.submit()
+
+ bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
+ self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
+ # v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20
+ self.assertEqual(
+ bom_scrap_row.qty,
+ 20,
+ f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}",
+ )
+
def test_disassembly_with_additional_rm_not_in_bom(self):
"""
- Test that disassembly correctly handles additional raw materials that were
- manually added during manufacturing (not part of the BOM).
+ Test that SE-linked disassembly includes additional raw materials
+ that were manually added during manufacturing (not part of the BOM).
Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item
- (not in BOM) in proportion to the manufactured qty
- 5. Create Disassembly for 4 units
- 6. Verify that the additional RM is included in disassembly with proportional qty
+ 5. Disassemble 3 units linked to first manufacture entry
+ 6. Verify additional RM is included with correct proportional qty from SE1
"""
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
@@ -2655,9 +2716,8 @@ class TestWorkOrder(FrappeTestCase):
se_for_material_transfer.save()
se_for_material_transfer.submit()
- # First Manufacture Entry - 3 units
+ # First Manufacture Entry - 3 units with additional RM
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
- # Additional RM
se_manufacture1.append(
"items",
{
@@ -2670,9 +2730,8 @@ class TestWorkOrder(FrappeTestCase):
se_manufacture1.save()
se_manufacture1.submit()
- # Second Manufacture Entry - 7 units
+ # Second Manufacture Entry - 7 units with additional RM
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
- # AAdditional RM
se_manufacture2.append(
"items",
{
@@ -2688,13 +2747,15 @@ class TestWorkOrder(FrappeTestCase):
wo.reload()
self.assertEqual(wo.produced_qty, 10)
- # Disassembly for 4 units
- disassemble_qty = 4
- stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
+ # Disassemble 3 units linked to first manufacture entry
+ disassemble_qty = 3
+ stock_entry = frappe.get_doc(
+ make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
+ )
stock_entry.save()
stock_entry.submit()
- # No duplicate
+ # No duplicates
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
@@ -2707,16 +2768,15 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
- # Additional RM qty
+ # Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
self.assertIsNotNone(
additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly",
)
- # intentional full reversal as not part of BOM
- # eg: dies or consumables used during manufacturing
- expected_additional_rm_qty = 3 + 7
+ # SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
+ expected_additional_rm_qty = 3
self.assertAlmostEqual(
additional_rm_row.qty,
expected_additional_rm_qty,
@@ -2724,7 +2784,7 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
)
- # RM qty
+ # BOM RM qty — scaled from SE1's rows
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
@@ -2740,6 +2800,7 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
+ # FG + 2 BOM RM + 1 additional RM = 4 items
expected_items = 4
self.assertEqual(
len(stock_entry.items),
@@ -2747,6 +2808,282 @@ class TestWorkOrder(FrappeTestCase):
f"Expected {expected_items} items, found {len(stock_entry.items)}",
)
+ # Verify traceability
+ for item in stock_entry.items:
+ self.assertEqual(item.against_stock_entry, se_manufacture1.name)
+ self.assertTrue(item.ste_detail)
+
+ def test_disassembly_auto_sets_source_stock_entry(self):
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import (
+ make_stock_entry as make_stock_entry_test_record,
+ )
+
+ raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
+ fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
+ bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
+
+ wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
+
+ make_stock_entry_test_record(
+ item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
+ )
+
+ se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
+ for item in se_transfer.items:
+ item.s_warehouse = wo.wip_warehouse
+ se_transfer.save()
+ se_transfer.submit()
+
+ se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
+ se_manufacture.submit()
+
+ # Disassemble without specifying source_stock_entry
+ stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
+ stock_entry.save()
+
+ # source_stock_entry should be auto-set since only one manufacture entry
+ self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
+
+ # All items should have against_stock_entry linked
+ for item in stock_entry.items:
+ self.assertEqual(item.against_stock_entry, se_manufacture.name)
+ self.assertTrue(item.ste_detail)
+
+ stock_entry.submit()
+
+ def test_disassembly_batch_tracked_items(self):
+ from erpnext.stock.doctype.batch.batch import make_batch
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import (
+ make_stock_entry as make_stock_entry_test_record,
+ )
+
+ wip_wh = "_Test Warehouse - _TC"
+
+ rm_item = make_item(
+ "Test Batch RM for Disassembly SB",
+ {
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBRD-RM-.###",
+ },
+ ).name
+ fg_item = make_item(
+ "Test Batch FG for Disassembly SB",
+ {
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBRD-FG-.###",
+ },
+ ).name
+
+ bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
+ wo = make_wo_order_test_record(
+ production_item=fg_item,
+ qty=6,
+ bom_no=bom.name,
+ skip_transfer=1,
+ source_warehouse=wip_wh,
+ status="Not Started",
+ )
+
+ # Two separate RM receipts → two distinct batches (batch_1, batch_2)
+ rm_receipt_1 = make_stock_entry_test_record(
+ item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
+ )
+ rm_batch_1 = get_batch_from_bundle(
+ frappe.db.get_value(
+ "Stock Entry Detail",
+ {"parent": rm_receipt_1.name, "item_code": rm_item},
+ "serial_and_batch_bundle",
+ )
+ )
+
+ rm_receipt_2 = make_stock_entry_test_record(
+ item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
+ )
+ rm_batch_2 = get_batch_from_bundle(
+ frappe.db.get_value(
+ "Stock Entry Detail",
+ {"parent": rm_receipt_2.name, "item_code": rm_item},
+ "serial_and_batch_bundle",
+ )
+ )
+
+ self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
+
+ fg_batch_1 = make_batch(frappe._dict(item=fg_item))
+ fg_batch_2 = make_batch(frappe._dict(item=fg_item))
+
+ # Manufacture entry 1 — 3 FG using batch_1 RM/FG
+ se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
+ for row in se_manufacture_1.items:
+ if row.item_code == rm_item:
+ row.batch_no = rm_batch_1
+ row.use_serial_batch_fields = 1
+ elif row.item_code == fg_item:
+ row.batch_no = fg_batch_1
+ row.use_serial_batch_fields = 1
+ se_manufacture_1.save()
+ se_manufacture_1.submit()
+
+ # Manufacture entry 2 — 3 FG using batch_2 RM/FG
+ se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
+ for row in se_manufacture_2.items:
+ if row.item_code == rm_item:
+ row.batch_no = rm_batch_2
+ row.use_serial_batch_fields = 1
+ elif row.item_code == fg_item:
+ row.batch_no = fg_batch_2
+ row.use_serial_batch_fields = 1
+ se_manufacture_2.save()
+ se_manufacture_2.submit()
+
+ # Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
+ disassemble_qty = 2
+ stock_entry = frappe.get_doc(
+ make_stock_entry(
+ wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
+ )
+ )
+ stock_entry.save()
+ stock_entry.submit()
+
+ # FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
+ fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
+ self.assertIsNotNone(fg_row)
+ self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
+ self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
+ self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
+
+ # RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
+ rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
+ self.assertIsNotNone(rm_row)
+ self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
+ self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
+ self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
+
+ # RM qty: 2 FG disassembled x 2 RM per FG = 4
+ self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
+
+ def test_disassembly_serial_tracked_items(self):
+ from frappe.model.naming import make_autoname
+
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import (
+ make_stock_entry as make_stock_entry_test_record,
+ )
+
+ wip_wh = "_Test Warehouse - _TC"
+
+ rm_item = make_item(
+ "Test Serial RM for Disassembly SB",
+ {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
+ ).name
+ fg_item = make_item(
+ "Test Serial FG for Disassembly SB",
+ {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
+ ).name
+
+ bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
+ wo = make_wo_order_test_record(
+ production_item=fg_item,
+ qty=6,
+ bom_no=bom.name,
+ skip_transfer=1,
+ source_warehouse=wip_wh,
+ status="Not Started",
+ )
+
+ # Two separate RM receipts → two disjoint sets of serial numbers
+ rm_receipt_1 = make_stock_entry_test_record(
+ item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
+ )
+ rm_serials_1 = get_serial_nos_from_bundle(
+ frappe.db.get_value(
+ "Stock Entry Detail",
+ {"parent": rm_receipt_1.name, "item_code": rm_item},
+ "serial_and_batch_bundle",
+ )
+ )
+ self.assertEqual(len(rm_serials_1), 6)
+
+ rm_receipt_2 = make_stock_entry_test_record(
+ item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
+ )
+ rm_serials_2 = get_serial_nos_from_bundle(
+ frappe.db.get_value(
+ "Stock Entry Detail",
+ {"parent": rm_receipt_2.name, "item_code": rm_item},
+ "serial_and_batch_bundle",
+ )
+ )
+ self.assertEqual(len(rm_serials_2), 6)
+ self.assertFalse(
+ set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
+ )
+
+ # Pre-generate two sets of FG serial numbers
+ series = frappe.db.get_value("Item", fg_item, "serial_no_series")
+ fg_serials_1 = [make_autoname(series) for _ in range(3)]
+ fg_serials_2 = [make_autoname(series) for _ in range(3)]
+
+ # Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
+ se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
+ for row in se_manufacture_1.items:
+ if row.item_code == rm_item:
+ row.serial_no = "\n".join(rm_serials_1)
+ row.use_serial_batch_fields = 1
+ elif row.item_code == fg_item:
+ row.serial_no = "\n".join(fg_serials_1)
+ row.use_serial_batch_fields = 1
+ se_manufacture_1.save()
+ se_manufacture_1.submit()
+
+ # Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
+ se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
+ for row in se_manufacture_2.items:
+ if row.item_code == rm_item:
+ row.serial_no = "\n".join(rm_serials_2)
+ row.use_serial_batch_fields = 1
+ elif row.item_code == fg_item:
+ row.serial_no = "\n".join(fg_serials_2)
+ row.use_serial_batch_fields = 1
+ se_manufacture_2.save()
+ se_manufacture_2.submit()
+
+ # Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
+ disassemble_qty = 2
+ stock_entry = frappe.get_doc(
+ make_stock_entry(
+ wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
+ )
+ )
+ stock_entry.save()
+ stock_entry.submit()
+
+ # FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
+ fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
+ self.assertIsNotNone(fg_row)
+ self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
+ fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
+ self.assertEqual(len(fg_dasm_serials), disassemble_qty)
+ self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
+ self.assertFalse(
+ set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
+ )
+
+ # RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
+ rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
+ self.assertIsNotNone(rm_row)
+ self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
+ rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
+ self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
+ self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
+ self.assertFalse(
+ set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
+ )
+
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index b6206cefcbb..6e20e789899 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", {
frm.trigger("add_custom_button_to_return_components");
frm.trigger("allow_alternative_item");
+ frm.trigger("toggle_items_editable");
+ },
+
+ toggle_items_editable(frm) {
+ let allow_edit = true;
+ if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
+
+ frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
+ frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
+
+ const grid = frm.fields_dict["required_items"].grid;
+ grid.update_docfield_property("item_code", "read_only", !allow_edit);
+ grid.update_docfield_property("required_qty", "read_only", !allow_edit);
+ grid.refresh();
},
add_custom_button_to_return_components: function (frm) {
@@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", {
make_disassembly_order(frm) {
erpnext.work_order
- .show_prompt_for_qty_input(frm, "Disassemble")
+ .show_disassembly_prompt(frm)
.then((data) => {
if (flt(data.qty) <= 0) {
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0."));
@@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", {
work_order_id: frm.doc.name,
purpose: "Disassemble",
qty: data.qty,
+ source_stock_entry: data.source_stock_entry,
});
})
.then((stock_entry) => {
- frappe.model.sync(stock_entry);
- frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
+ if (stock_entry) {
+ frappe.model.sync(stock_entry);
+ frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
+ }
});
},
@@ -865,6 +882,60 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
+ show_disassembly_prompt: function (frm) {
+ let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
+
+ let fields = [
+ {
+ fieldtype: "Link",
+ label: __("Source Manufacture Entry"),
+ fieldname: "source_stock_entry",
+ options: "Stock Entry",
+ description: __("Optional. Select a specific manufacture entry to reverse."),
+ get_query: () => {
+ return {
+ filters: {
+ work_order: frm.doc.name,
+ purpose: "Manufacture",
+ docstatus: 1,
+ },
+ };
+ },
+ onchange: async function () {
+ if (!frm.disassembly_prompt) return;
+
+ let se_name = this.value;
+ let qty = max_qty;
+ if (se_name) {
+ qty = await frappe.xcall(
+ "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
+ { stock_entry_name: se_name }
+ );
+ }
+
+ frm.disassembly_prompt.set_value("qty", qty);
+ frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
+ },
+ },
+ {
+ fieldtype: "Float",
+ label: __("Qty for {0}", [__("Disassemble")]),
+ fieldname: "qty",
+ description: __("Max: {0}", [max_qty]),
+ default: max_qty,
+ },
+ ];
+
+ return new Promise((resolve, reject) => {
+ frm.disassembly_prompt = frappe.prompt(
+ fields,
+ (data) => resolve(data),
+ __("Disassemble"),
+ __("Create")
+ );
+ });
+ },
+
show_prompt_for_qty_input: function (frm, purpose) {
let max = this.get_max_transferable_qty(frm, purpose);
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index bc3def1186f..8bc2e7c1953 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -141,6 +141,7 @@ class WorkOrder(Document):
def onload(self):
ms = frappe.get_doc("Manufacturing Settings")
+ self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
self.set_onload("material_consumption", ms.material_consumption)
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
@@ -167,7 +168,11 @@ class WorkOrder(Document):
validate_uom_is_integer(self, "stock_uom", ["required_qty"])
- self.set_required_items(reset_only_qty=len(self.get("required_items")))
+ if not len(self.get("required_items")) or not frappe.db.get_single_value(
+ "Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
+ ):
+ self.set_required_items(reset_only_qty=len(self.get("required_items")))
+
self.validate_operations_sequence()
def validate_operations_sequence(self):
@@ -1480,7 +1485,13 @@ def set_work_order_ops(name):
@frappe.whitelist()
-def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
+def make_stock_entry(
+ work_order_id: str,
+ purpose: str,
+ qty: float | None = None,
+ target_warehouse: str | None = None,
+ source_stock_entry: str | None = None,
+):
work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
wip_warehouse = work_order.wip_warehouse
@@ -1517,6 +1528,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_warehouse
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
+ if source_stock_entry:
+ stock_entry.source_stock_entry = source_stock_entry
stock_entry.set_stock_entry_type()
stock_entry.get_items()
@@ -1527,6 +1540,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
return stock_entry.as_dict()
+@frappe.whitelist()
+def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
+ se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
+ if not se:
+ return 0.0
+
+ filters = {
+ "source_stock_entry": stock_entry_name,
+ "purpose": "Disassemble",
+ "docstatus": 1,
+ }
+
+ if current_se_name:
+ filters["name"] = ("!=", current_se_name)
+
+ already_disassembled = flt(
+ frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None)
+ )
+
+ return flt(se.fg_completed_qty) - already_disassembled
+
+
@frappe.whitelist()
def get_default_warehouse():
doc = frappe.get_cached_doc("Manufacturing Settings")
diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
index 580168180a7..98ee0a63d53 100644
--- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
+++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json
@@ -151,7 +151,7 @@
],
"istable": 1,
"links": [],
- "modified": "2024-11-19 15:48:16.823384",
+ "modified": "2025-12-02 11:16:05.081613",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index e17fa3d622c..cd20cd340c2 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -8,6 +8,7 @@ import frappe
from frappe.tests.utils import change_settings
from frappe.utils import add_to_date, now_datetime, nowdate
+from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
from erpnext.setup.doctype.employee.test_employee import make_employee
@@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase):
ts.calculate_percentage_billed()
self.assertEqual(ts.per_billed, 100)
+ def test_partial_billing_and_return(self):
+ """
+ Test Timesheet status transitions during partial billing, full billing,
+ sales return, and return cancellation.
+ Scenario:
+ 1. Create a Timesheet with two billable time logs.
+ 2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
+ 3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
+ 4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
+ 5. Cancel the Sales Return → Timesheet returns to Billed status.
+ This test ensures Timesheet status is recalculated correctly
+ across billing and return lifecycle events.
+ """
+ emp = make_employee("test_employee_6@salary.com")
+
+ timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
+ timesheet_detail = timesheet.append("time_logs", {})
+ timesheet_detail.is_billable = 1
+ timesheet_detail.activity_type = "_Test Activity Type"
+ timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
+ timesheet_detail.hours = 2
+ timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
+ hours=timesheet_detail.hours
+ )
+ timesheet.save().submit()
+
+ sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
+ sales_invoice.due_date = nowdate()
+ sales_invoice.timesheets.pop()
+ sales_invoice.submit()
+
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Partially Billed")
+
+ sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
+ sales_invoice2.due_date = nowdate()
+ sales_invoice2.submit()
+
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Billed")
+
+ sales_return = make_sales_return(sales_invoice2.name).submit()
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Partially Billed")
+
+ sales_return.load_from_db()
+ sales_return.cancel()
+
+ timesheet.load_from_db()
+ self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
+ self.assertEqual(timesheet.status, "Billed")
+
def make_timesheet(
employee,
@@ -211,6 +264,7 @@ def make_timesheet(
project=None,
task=None,
company=None,
+ do_not_submit=False,
):
update_activity_type(activity_type)
timesheet = frappe.new_doc("Timesheet")
@@ -237,7 +291,8 @@ def make_timesheet(
else:
timesheet.save(ignore_permissions=True)
- timesheet.submit()
+ if not do_not_submit:
+ timesheet.submit()
return timesheet
diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json
index ba6262dc3de..6f16266e0f3 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.json
+++ b/erpnext/projects/doctype/timesheet/timesheet.json
@@ -91,7 +91,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
- "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
+ "options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1
},
@@ -310,7 +310,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-04-20 15:59:11.107831",
+ "modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index ec58c55f020..bf3116cb409 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -50,7 +50,9 @@ class Timesheet(Document):
per_billed: DF.Percent
sales_invoice: DF.Link | None
start_date: DF.Date | None
- status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
+ status: DF.Literal[
+ "Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
+ ]
time_logs: DF.Table[TimesheetDetail]
title: DF.Data | None
total_billable_amount: DF.Currency
@@ -126,6 +128,9 @@ class Timesheet(Document):
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
self.status = "Billed"
+ if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
+ self.status = "Partially Billed"
+
if self.sales_invoice:
self.status = "Completed"
@@ -423,7 +428,9 @@ def get_timesheet_data(name, project):
@frappe.whitelist()
-def make_sales_invoice(source_name, item_code=None, customer=None, currency=None):
+def make_sales_invoice(
+ source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None
+):
target = frappe.new_doc("Sales Invoice")
timesheet = frappe.get_doc("Timesheet", source_name)
@@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
for time_log in timesheet.time_logs:
- if time_log.is_billable:
+ if time_log.is_billable and not time_log.sales_invoice:
target.append(
"timesheets",
{
diff --git a/erpnext/projects/doctype/timesheet/timesheet_list.js b/erpnext/projects/doctype/timesheet/timesheet_list.js
index 0de568ce589..ceca47209e1 100644
--- a/erpnext/projects/doctype/timesheet/timesheet_list.js
+++ b/erpnext/projects/doctype/timesheet/timesheet_list.js
@@ -1,6 +1,9 @@
frappe.listview_settings["Timesheet"] = {
add_fields: ["status", "total_hours", "start_date", "end_date"],
get_indicator: function (doc) {
+ if (doc.status == "Partially Billed") {
+ return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
+ }
if (doc.status == "Billed") {
return [__("Billed"), "green", "status,=," + "Billed"];
}
diff --git a/erpnext/regional/address_template/templates/croatia.html b/erpnext/regional/address_template/templates/croatia.html
new file mode 100644
index 00000000000..0c2ed73f0ae
--- /dev/null
+++ b/erpnext/regional/address_template/templates/croatia.html
@@ -0,0 +1,4 @@
+{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%}
+{{ pincode }} {{ city | upper }}
+{{ country | upper }}
\ No newline at end of file
diff --git a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py
index cab84024417..8c2b559fe52 100644
--- a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py
+++ b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py
@@ -148,6 +148,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test UAE Customer",
"customer_type": "Company",
+ "customer_group": "Individual",
}
)
customer.insert()
diff --git a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py
index a898a251043..26b2969e2c5 100644
--- a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py
+++ b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py
@@ -115,6 +115,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test SA Customer",
"customer_type": "Company",
+ "customer_group": "Individual",
}
).insert()
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 1ce480fbd2e..8b5761f5930 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -145,6 +145,7 @@ class Customer(TransactionBase):
def validate(self):
self.flags.is_new_doc = self.is_new()
self.flags.old_lead = self.lead_name
+ self.validate_customer_group()
validate_party_accounts(self)
self.validate_credit_limit_on_change()
self.set_loyalty_program()
@@ -324,6 +325,17 @@ class Customer(TransactionBase):
frappe.NameError,
)
+ def validate_customer_group(self):
+ if not self.customer_group:
+ return
+
+ is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group")
+ if is_group:
+ frappe.throw(
+ _("Cannot select a Group type Customer Group. Please select a non-group Customer Group."),
+ title=_("Invalid Customer Group"),
+ )
+
def validate_credit_limit_on_change(self):
if self.get("__islocal") or not self.credit_limits:
return
diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index dfb4a5b4445..a8fd5ed76ca 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -450,6 +450,7 @@ def make_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
+ customer.customer_group = "Individual"
customer.insert()
return customer.name
else:
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 38334cc29bc..1316252005f 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", {
});
}
},
+ transaction_date(frm) {
+ prevent_past_delivery_dates(frm);
+ frm.set_value("delivery_date", "");
+ frm.doc.items.forEach((d) => {
+ frappe.model.set_value(d.doctype, d.name, "delivery_date", "");
+ });
+ },
refresh: function (frm) {
if (frm.doc.docstatus === 1) {
diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py
index 543a8a194b8..db4446cce77 100755
--- a/erpnext/setup/doctype/employee/employee.py
+++ b/erpnext/setup/doctype/employee/employee.py
@@ -189,7 +189,7 @@ class Employee(NestedSet):
frappe.throw(_("User {0} does not exist").format(self.user_id))
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
- frappe.set_value("User", self.user_id, "enabled", not enabled)
+ frappe.db.set_value("User", self.user_id, "enabled", not enabled)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")
diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py
index 7349838e816..fbef891b745 100644
--- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py
+++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py
@@ -181,6 +181,7 @@ class InventoryDimension(Document):
insert_after="inventory_dimension",
options=self.reference_document,
label=_(label),
+ depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "",
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
@@ -273,7 +274,7 @@ class InventoryDimension(Document):
elif doctype != "Stock Entry Detail":
display_depends_on = "eval:parent.is_internal_customer == 1"
elif doctype == "Stock Entry Detail":
- display_depends_on = "eval:parent.purpose != 'Material Issue'"
+ display_depends_on = "eval:doc.t_warehouse"
fieldname = f"{fieldname_start_with}_{self.source_fieldname}"
label = f"{label_start_with} {self.dimension_name}"
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index e9291e10baf..f200294a777 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -510,7 +510,7 @@ class PurchaseReceipt(BuyingController):
else flt(item.net_amount, item.precision("net_amount"))
)
- outgoing_amount = item.base_net_amount
+ outgoing_amount = item.qty * item.base_net_rate
if self.is_internal_transfer() and item.valuation_rate:
outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
@@ -1135,9 +1135,11 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
+ billed_qty_amt = frappe._dict()
if adjust_incoming_rate:
- item_wise_billed_qty = get_billed_qty_against_purchase_receipt(pr_doc)
+ billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc)
+ billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc)
for item in pr_doc.items:
returned_qty = flt(item_wise_returned_qty.get(item.name))
@@ -1166,13 +1168,47 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
if (
item.billed_amt is not None
and item.amount is not None
- and item_wise_billed_qty.get(item.name)
+ and (
+ billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item)
+ )
):
- adjusted_amt = (
- flt(item.billed_amt / item_wise_billed_qty.get(item.name)) - flt(item.rate)
- ) * item.qty
+ qty = None
+ if billed_qty_amt.get(item.name):
+ qty = billed_qty_amt.get(item.name).get("qty")
- adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount"))
+ if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item):
+ if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]:
+ qty = item.qty
+ else:
+ qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]
+
+ billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty
+
+ billed_amt = item.billed_amt
+ if billed_qty_amt.get(item.name):
+ billed_amt = flt(billed_qty_amt.get(item.name).get("amount"))
+ elif billed_qty_amt_based_on_po.get(item.purchase_order_item):
+ total_billed_qty = (
+ billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty
+ )
+
+ if total_billed_qty:
+ billed_amt = flt(
+ flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount"))
+ * (qty / total_billed_qty)
+ )
+ else:
+ billed_amt = 0.0
+
+ # Reduce billed amount based on PO for next iterations
+ billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt
+
+ if qty:
+ adjusted_amt = (
+ flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate))
+ ) * item.qty
+
+ adjusted_amt = flt(adjusted_amt, item.precision("amount"))
pi_landed_cost_amount += adjusted_amt
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif amount and item.billed_amt > amount:
@@ -1201,20 +1237,80 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
adjust_incoming_rate_for_pr(pr_doc)
-def get_billed_qty_against_purchase_receipt(pr_doc):
+def get_billed_qty_amount_against_purchase_receipt(pr_doc):
pr_names = [d.name for d in pr_doc.items]
+ parent_table = frappe.qb.DocType("Purchase Invoice")
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
- frappe.qb.from_(table)
- .select(table.pr_detail, fn.Sum(table.qty).as_("qty"))
+ frappe.qb.from_(parent_table)
+ .inner_join(table)
+ .on(parent_table.name == table.parent)
+ .select(
+ table.pr_detail,
+ fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
+ fn.Sum(table.qty).as_("qty"),
+ )
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
.groupby(table.pr_detail)
)
- invoice_data = query.run(as_list=1)
+ invoice_data = query.run(as_dict=1)
if not invoice_data:
return frappe._dict()
- return frappe._dict(invoice_data)
+
+ billed_qty_amt = frappe._dict()
+
+ for row in invoice_data:
+ if row.pr_detail not in billed_qty_amt:
+ billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0}
+
+ billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount)
+ billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty)
+
+ return billed_qty_amt
+
+
+def get_billed_qty_amount_against_purchase_order(pr_doc):
+ po_names = list(
+ set(
+ [
+ d.purchase_order_item
+ for d in pr_doc.items
+ if d.purchase_order_item and not d.purchase_invoice_item
+ ]
+ )
+ )
+
+ invoice_data_po_based = frappe._dict()
+ if po_names:
+ parent_table = frappe.qb.DocType("Purchase Invoice")
+ table = frappe.qb.DocType("Purchase Invoice Item")
+
+ query = (
+ frappe.qb.from_(parent_table)
+ .inner_join(table)
+ .on(parent_table.name == table.parent)
+ .select(
+ table.po_detail,
+ fn.Sum(table.qty).as_("qty"),
+ fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
+ )
+ .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
+ .groupby(table.po_detail)
+ )
+
+ invoice_data = query.run(as_dict=1)
+ if not invoice_data:
+ return frappe._dict()
+
+ for row in invoice_data:
+ if row.po_detail not in invoice_data_po_based:
+ invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0}
+
+ invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount)
+ invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty)
+
+ return invoice_data_po_based
def adjust_incoming_rate_for_pr(doc):
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 8508031578d..2d2e68bb0ea 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -4539,7 +4539,7 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(srbnb_cost, 1500)
- def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
+ def test_valuation_rate_for_rejected_materials_without_accepted_materials(self):
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"
@@ -5106,6 +5106,97 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(row.incoming_rate, 100)
+ def test_bill_for_rejected_quantity_in_purchase_invoice(self):
+ item_code = make_item("Test Rejected Qty", {"is_stock_item": 1}).name
+
+ frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
+ pr = make_purchase_receipt(
+ item_code=item_code,
+ qty=10,
+ rejected_qty=2,
+ rate=10,
+ warehouse="_Test Warehouse - _TC",
+ )
+
+ self.assertEqual(pr.total_qty, 10)
+ self.assertEqual(pr.total, 100)
+
+ frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
+ pr = make_purchase_receipt(
+ item_code=item_code,
+ qty=10,
+ rejected_qty=2,
+ rate=10,
+ warehouse="_Test Warehouse - _TC",
+ )
+
+ self.assertEqual(pr.total_qty, 12)
+ self.assertEqual(pr.total, 120)
+
+ def test_different_exchange_rate_in_pr_and_pi(self):
+ from erpnext.accounts.doctype.account.test_account import create_account
+
+ original_value = frappe.db.get_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
+ )
+
+ frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
+
+ party_account = create_account(
+ account_name="USD Party Account Creditors",
+ parent_account="Accounts Payable - TCP1",
+ account_type="Payable",
+ company="_Test Company with perpetual inventory",
+ account_currency="USD",
+ )
+
+ supplier = create_supplier(
+ supplier_name="_Test USD Supplier New 1", default_currency="USD", party_account=party_account
+ ).name
+ item_code = make_item("Test Item for Different Exchange Rate", {"is_stock_item": 1}).name
+
+ pr = make_purchase_receipt(
+ item_code=item_code,
+ qty=1,
+ currency="USD",
+ conversion_rate=80,
+ rate=100,
+ company="_Test Company with perpetual inventory",
+ warehouse=frappe.get_value(
+ "Warehouse", {"company": "_Test Company with perpetual inventory"}, "name"
+ ),
+ supplier=supplier,
+ )
+
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.conversion_rate, 80)
+
+ gl_entries = get_gl_entries(pr.doctype, pr.name)
+ self.assertTrue(len(gl_entries) == 2)
+ for row in gl_entries:
+ amount = row.credit or row.debit
+ self.assertEqual(amount, 8000.0)
+
+ pi = make_purchase_invoice(pr.name)
+ pi.conversion_rate = 90
+ pi.currency = "USD"
+
+ pi.save()
+ pi.submit()
+
+ gl_entries = get_gl_entries(pi.doctype, pi.name)
+ self.assertTrue(len(gl_entries) == 2)
+
+ accounts = ["USD Party Account Creditors - TCP1", "Stock Received But Not Billed - TCP1"]
+ for row in gl_entries:
+ amount = row.credit or row.debit
+ self.assertEqual(amount, 9000.0)
+ self.assertTrue(row.account in accounts)
+
+ frappe.db.set_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
+ )
+
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
@@ -5276,6 +5367,9 @@ def make_purchase_receipt(**args):
pr.return_against = args.return_against
pr.apply_putaway_rule = args.apply_putaway_rule
+ if args.get("conversion_rate") is not None:
+ pr.conversion_rate = args.conversion_rate
+
qty = args.qty if args.qty is not None else 5
rejected_qty = args.rejected_qty or 0
received_qty = args.received_qty or flt(rejected_qty) + flt(qty)
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
index e6547ad6f35..c514b25c8ef 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
@@ -67,9 +67,15 @@ frappe.ui.form.on("Repost Item Valuation", {
}
if (frm.doc.status == "In Progress") {
- frm.doc.current_index = data.current_index;
- frm.doc.items_to_be_repost = data.items_to_be_repost;
- frm.doc.total_reposting_count = data.total_reposting_count;
+ if (data.current_index) {
+ frm.doc.current_index = data.current_index;
+ frm.doc.items_to_be_repost = data.items_to_be_repost;
+ }
+
+ if (data.vouchers_posted) {
+ frm.doc.total_vouchers = data.total_vouchers;
+ frm.doc.vouchers_posted = data.vouchers_posted;
+ }
frm.dashboard.reset();
frm.trigger("show_reposting_progress");
@@ -104,15 +110,31 @@ frappe.ui.form.on("Repost Item Valuation", {
show_reposting_progress: function (frm) {
var bars = [];
-
+ let title = "";
+ let progress = 0.0;
let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0;
- if (frm.doc?.total_reposting_count) {
- total_count = frm.doc.total_reposting_count;
+ if (total_count > 1) {
+ progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5;
+ title = __("Reposting for Item-Wh Completed {0}%", [progress]);
+
+ bars.push({
+ title: title,
+ width: progress + "%",
+ progress_class: "progress-bar-success",
+ });
+
+ frm.dashboard.add_progress(__("Reposting Progress"), bars);
}
- let progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5;
- var title = __("Reposting Completed {0}%", [progress]);
+ if (!frm.doc.vouchers_posted) {
+ return;
+ }
+
+ // Show voucher posting progress if vouchers are being reposted
+ bars = [];
+ progress = flt((cint(frm.doc.vouchers_posted) / cint(frm.doc.total_vouchers)) * 100, 2) || 0.5;
+ title = __("Reposting for Vouchers Completed {0}%", [progress]);
bars.push({
title: title,
@@ -120,7 +142,7 @@ frappe.ui.form.on("Repost Item Valuation", {
progress_class: "progress-bar-success",
});
- frm.dashboard.add_progress(__("Reposting Progress"), bars);
+ frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars);
},
restart_reposting: function (frm) {
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
index bd70072e4bd..3affd1e4be9 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
@@ -24,14 +24,16 @@
"error_section",
"error_log",
"reposting_info_section",
- "reposting_data_file",
"items_to_be_repost",
- "distinct_item_and_warehouse",
"column_break_o1sj",
"total_reposting_count",
"current_index",
"gl_reposting_index",
- "affected_transactions"
+ "reposting_data_file",
+ "vouchers_based_on_item_and_warehouse_section",
+ "total_vouchers",
+ "column_break_yqwo",
+ "vouchers_posted"
],
"fields": [
{
@@ -164,15 +166,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "distinct_item_and_warehouse",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Distinct Item and Warehouse",
- "no_copy": 1,
- "print_hide": 1,
- "read_only": 1
- },
{
"fieldname": "current_index",
"fieldtype": "Int",
@@ -182,14 +175,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "affected_transactions",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Affected Transactions",
- "no_copy": 1,
- "read_only": 1
- },
{
"default": "0",
"fieldname": "gl_reposting_index",
@@ -202,7 +187,7 @@
{
"fieldname": "reposting_info_section",
"fieldtype": "Section Break",
- "label": "Reposting Info"
+ "label": "Reposting Item and Warehouse"
},
{
"fieldname": "column_break_o1sj",
@@ -211,14 +196,7 @@
{
"fieldname": "total_reposting_count",
"fieldtype": "Int",
- "label": "Total Reposting Count",
- "no_copy": 1,
- "read_only": 1
- },
- {
- "fieldname": "reposting_data_file",
- "fieldtype": "Attach",
- "label": "Reposting Data File",
+ "label": "No of Items to Repost",
"no_copy": 1,
"read_only": 1
},
@@ -228,13 +206,44 @@
"fieldname": "recreate_stock_ledgers",
"fieldtype": "Check",
"label": "Recreate Stock Ledgers"
+ },
+ {
+ "fieldname": "vouchers_based_on_item_and_warehouse_section",
+ "fieldtype": "Section Break",
+ "hidden": 1,
+ "label": "Reposting Vouchers"
+ },
+ {
+ "fieldname": "total_vouchers",
+ "fieldtype": "Int",
+ "label": "Total Ledgers",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_yqwo",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "vouchers_posted",
+ "fieldtype": "Int",
+ "label": "Ledgers Posted",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "reposting_data_file",
+ "fieldtype": "Attach",
+ "label": "Reposting Data File",
+ "no_copy": 1,
+ "read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2025-03-31 12:38:20.566196",
+ "modified": "2026-03-27 19:59:58.637964",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
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 e3b1b330fad..44cf9280780 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -33,14 +33,12 @@ class RepostItemValuation(Document):
if TYPE_CHECKING:
from frappe.types import DF
- affected_transactions: DF.Code | None
allow_negative_stock: DF.Check
allow_zero_rate: DF.Check
amended_from: DF.Link | None
based_on: DF.Literal["Transaction", "Item and Warehouse"]
company: DF.Link | None
current_index: DF.Int
- distinct_item_and_warehouse: DF.Code | None
error_log: DF.LongText | None
gl_reposting_index: DF.Int
item_code: DF.Link | None
@@ -49,11 +47,14 @@ class RepostItemValuation(Document):
posting_time: DF.Time | None
recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None
- status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"]
+ reposting_reference: DF.Data | None
+ status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"]
total_reposting_count: DF.Int
+ total_vouchers: DF.Int
via_landed_cost_voucher: DF.Check
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
+ vouchers_posted: DF.Int
warehouse: DF.Link | None
# end: auto-generated types
@@ -74,6 +75,7 @@ class RepostItemValuation(Document):
def validate(self):
self.set_company()
+ self.validate_update_stock()
self.validate_period_closing_voucher()
self.set_status(write=False)
self.reset_field_values()
@@ -81,6 +83,15 @@ class RepostItemValuation(Document):
self.reset_recreate_stock_ledgers()
self.validate_recreate_stock_ledgers()
+ def validate_update_stock(self):
+ if self.voucher_type in ["Sales Invoice", "Purchase Invoice"]:
+ update_stock = frappe.get_value(self.voucher_type, self.voucher_no, "update_stock")
+ if not update_stock:
+ msg = _(
+ "Since {0} has 'Update Stock' disabled, you cannot create repost item valuation against it"
+ ).format(get_link_to_form(self.voucher_type, self.voucher_no))
+ frappe.throw(msg)
+
def validate_recreate_stock_ledgers(self):
if not self.recreate_stock_ledgers:
return
@@ -250,6 +261,9 @@ class RepostItemValuation(Document):
self.distinct_item_and_warehouse = None
self.items_to_be_repost = None
self.gl_reposting_index = 0
+ self.total_reposting_count = 0
+ self.total_vouchers = 0
+ self.vouchers_posted = 0
self.clear_attachment()
self.db_update()
@@ -381,7 +395,7 @@ def repost_sl_entries(doc):
)
else:
repost_future_sle(
- args=[
+ items_to_be_repost=[
frappe._dict(
{
"item_code": doc.item_code,
diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
index c36d799ba37..5cc3736fb0f 100644
--- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
@@ -137,19 +137,14 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
item_code="_Test Item",
warehouse="_Test Warehouse - _TC",
based_on="Item and Warehouse",
- voucher_type="Sales Invoice",
- voucher_no="SI-1",
posting_date="2021-01-02",
posting_time="00:01:00",
)
-
# new repost without any duplicates
riv1 = frappe.get_doc(riv_args)
riv1.flags.dont_run_in_test = True
riv1.submit()
_assert_status(riv1, "Queued")
- self.assertEqual(riv1.voucher_type, "Sales Invoice") # traceability
- self.assertEqual(riv1.voucher_no, "SI-1")
# newer than existing duplicate - riv1
riv2 = frappe.get_doc(riv_args.update({"posting_date": "2021-01-03"}))
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index 4de6ebc6a00..b0b8c221f8e 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -400,6 +400,25 @@ class SerialandBatchBundle(Document):
def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None):
if valuation_details := self.get_valuation_rate_for_return_entry(return_against):
+ from erpnext.stock.utils import get_valuation_method
+
+ valuation_method = get_valuation_method(self.item_code)
+
+ stock_queue = []
+ non_batchwise_batches = []
+ if not self.has_serial_no and valuation_method == "FIFO":
+ non_batchwise_batches = frappe.get_all(
+ "Batch",
+ filters={
+ "name": ("in", [d.batch_no for d in self.entries if d.batch_no]),
+ "use_batchwise_valuation": 0,
+ },
+ pluck="name",
+ )
+
+ if non_batchwise_batches and prev_sle and prev_sle.stock_queue:
+ stock_queue = parse_json(prev_sle.stock_queue)
+
for row in self.entries:
if valuation_details:
self.validate_returned_serial_batch_no(return_against, row, valuation_details)
@@ -421,11 +440,25 @@ class SerialandBatchBundle(Document):
row.incoming_rate = flt(valuation_rate)
row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate)
+ if (
+ non_batchwise_batches
+ and row.batch_no in non_batchwise_batches
+ and row.incoming_rate is not None
+ ):
+ if flt(row.qty) > 0:
+ stock_queue.append([row.qty, row.incoming_rate])
+ elif flt(row.qty) < 0:
+ stock_queue = FIFOValuation(stock_queue)
+ stock_queue.remove_stock(qty=abs(row.qty))
+ stock_queue = stock_queue.state
+ row.stock_queue = json.dumps(stock_queue)
+
if save:
row.db_set(
{
"incoming_rate": row.incoming_rate,
"stock_value_difference": row.stock_value_difference,
+ "stock_queue": row.get("stock_queue"),
}
)
@@ -1446,6 +1479,7 @@ class SerialandBatchBundle(Document):
def on_cancel(self):
self.validate_voucher_no_docstatus()
self.validate_batch_quantity()
+ self.remove_source_document_no()
def validate_batch_quantity(self):
if not self.has_batch_no:
@@ -1464,6 +1498,19 @@ class SerialandBatchBundle(Document):
if flt(available_qty, precision) < 0:
self.throw_negative_batch(d.batch_no, available_qty, precision)
+ def remove_source_document_no(self):
+ if not self.has_serial_no:
+ return
+
+ if self.total_qty > 0:
+ serial_nos = [d.serial_no for d in self.entries if d.serial_no]
+ sn_table = frappe.qb.DocType("Serial No")
+ (
+ frappe.qb.update(sn_table)
+ .set(sn_table.purchase_document_no, None)
+ .where((sn_table.name.isin(serial_nos)) & (sn_table.purchase_document_no == self.voucher_no))
+ ).run()
+
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
from erpnext.stock.stock_ledger import NegativeStockError
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
index 51b939c343d..5e33852badc 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
@@ -1071,6 +1071,126 @@ class TestSerialandBatchBundle(FrappeTestCase):
self.assertTrue(bundle_doc.docstatus == 0)
self.assertRaises(frappe.ValidationError, bundle_doc.submit)
+ def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ batch_item_code = "Old Batch Return Queue Test"
+ make_item(
+ batch_item_code,
+ {
+ "has_batch_no": 1,
+ "batch_number_series": "TEST-RET-Q-.#####",
+ "create_new_batch": 1,
+ "is_stock_item": 1,
+ "valuation_method": "FIFO",
+ },
+ )
+
+ batch_id = "Old Batch Return Queue 1"
+ if not frappe.db.exists("Batch", batch_id):
+ batch_doc = frappe.get_doc(
+ {
+ "doctype": "Batch",
+ "batch_id": batch_id,
+ "item": batch_item_code,
+ "use_batchwise_valuation": 0,
+ }
+ ).insert(ignore_permissions=True)
+
+ batch_doc.db_set(
+ {
+ "use_batchwise_valuation": 0,
+ "batch_qty": 0,
+ }
+ )
+
+ # Create initial stock with FIFO queue: [[10, 100], [20, 200]]
+ make_stock_entry(
+ item_code=batch_item_code,
+ target="_Test Warehouse - _TC",
+ qty=10,
+ rate=100,
+ batch_no=batch_id,
+ use_serial_batch_fields=True,
+ )
+
+ make_stock_entry(
+ item_code=batch_item_code,
+ target="_Test Warehouse - _TC",
+ qty=20,
+ rate=200,
+ batch_no=batch_id,
+ use_serial_batch_fields=True,
+ )
+
+ # Purchase Receipt: inward 5 @ 300
+ pr = make_purchase_receipt(
+ item_code=batch_item_code,
+ warehouse="_Test Warehouse - _TC",
+ qty=5,
+ rate=300,
+ batch_no=batch_id,
+ use_serial_batch_fields=True,
+ )
+
+ sle = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": pr.name},
+ ["stock_queue"],
+ as_dict=True,
+ )
+
+ # Stock queue should now be [[10, 100], [20, 200], [5, 300]]
+ self.assertEqual(json.loads(sle.stock_queue), [[10, 100], [20, 200], [5, 300]])
+
+ # Purchase Return: return 5 against the PR
+ return_pr = make_return_doc("Purchase Receipt", pr.name)
+ return_pr.submit()
+
+ return_sle = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_pr.name},
+ ["stock_queue"],
+ as_dict=True,
+ )
+
+ # Stock queue should have 5 removed via FIFO from [[10, 100], [20, 200], [5, 300]]
+ # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged
+ self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]])
+
+ def test_reference_voucher_on_cancel(self):
+ """
+ When a source document is cancelled, the reference voucher field
+ in the respective serial or batch document should be nullified.
+ """
+
+ item_code = make_item(
+ "Serial Item",
+ properties={
+ "is_stock_item": 1,
+ "has_serial_no": 1,
+ "serial_no_series": "SERIAL.#####",
+ },
+ ).name
+
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=1,
+ target="_Test Warehouse - _TC",
+ )
+ serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0]
+ self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se.name)
+
+ se.cancel()
+ self.assertIsNone(frappe.get_value("Serial No", serial_no, "purchase_document_no"))
+
+ se1 = frappe.copy_doc(se, ignore_no_copy=False)
+ se1.items[0].serial_no = serial_no
+ se1.submit()
+
+ self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name)
+
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos
diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py
index 1c91a054ebc..41ddc6ea52e 100644
--- a/erpnext/stock/doctype/shipment/test_shipment.py
+++ b/erpnext/stock/doctype/shipment/test_shipment.py
@@ -177,7 +177,7 @@ def create_shipment_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Company"
- customer.customer_group = "All Customer Groups"
+ customer.customer_group = "Individual"
customer.territory = "All Territories"
customer.insert()
return customer
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index fd2860a6f58..3e17748bb8f 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -36,6 +36,16 @@ frappe.ui.form.on("Stock Entry", {
};
});
+ frm.set_query("source_stock_entry", function () {
+ return {
+ filters: {
+ purpose: "Manufacture",
+ docstatus: 1,
+ work_order: frm.doc.work_order || undefined,
+ },
+ };
+ });
+
frm.set_query("source_warehouse_address", function () {
return {
query: "erpnext.controllers.queries.get_warehouse_address",
@@ -219,6 +229,30 @@ frappe.ui.form.on("Stock Entry", {
});
},
+ source_stock_entry: async function (frm) {
+ if (!frm.doc.source_stock_entry || frm.doc.purpose !== "Disassemble") return;
+
+ if (frm._via_source_stock_entry) {
+ frm.call({
+ doc: frm.doc,
+ method: "get_items",
+ callback: function (r) {
+ if (!r.exc) refresh_field("items");
+ },
+ });
+ frm._via_source_stock_entry = false;
+ return;
+ }
+
+ let available_qty = await frappe.xcall(
+ "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
+ { stock_entry_name: frm.doc.source_stock_entry }
+ );
+
+ // triggers get_items() via its onchange
+ await frm.set_value("fg_completed_qty", available_qty);
+ },
+
outgoing_stock_entry: function (frm) {
frappe.call({
doc: frm.doc,
@@ -315,6 +349,59 @@ frappe.ui.form.on("Stock Entry", {
__("View")
);
}
+
+ if (frm.doc.purpose === "Manufacture") {
+ frm.add_custom_button(
+ __("Disassemble"),
+ async function () {
+ let available_qty = await frappe.xcall(
+ "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
+ { stock_entry_name: frm.doc.name }
+ );
+ frappe.prompt(
+ {
+ fieldtype: "Float",
+ label: __("Qty to Disassemble"),
+ fieldname: "qty",
+ default: available_qty,
+ description: __("Max: {0}", [available_qty]),
+ },
+ async (data) => {
+ if (frm.doc.work_order) {
+ let stock_entry = await frappe.xcall(
+ "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
+ {
+ work_order_id: frm.doc.work_order,
+ purpose: "Disassemble",
+ qty: data.qty,
+ source_stock_entry: frm.doc.name,
+ }
+ );
+ if (stock_entry) {
+ frappe.model.sync(stock_entry);
+ frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
+ }
+ } else {
+ let se = frappe.model.get_new_doc("Stock Entry");
+ se.company = frm.doc.company;
+ se.stock_entry_type = "Disassemble";
+ se.purpose = "Disassemble";
+ se.source_stock_entry = frm.doc.name;
+ se.from_bom = frm.doc.from_bom;
+ se.bom_no = frm.doc.bom_no;
+ se.fg_completed_qty = data.qty;
+ frm._via_source_stock_entry = true;
+
+ frappe.set_route("Form", "Stock Entry", se.name);
+ }
+ },
+ __("Disassemble"),
+ __("Create")
+ );
+ },
+ __("Create")
+ );
+ }
}
if (frm.doc.docstatus === 0) {
@@ -1279,8 +1366,11 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no)
frappe.throw(__("BOM and Manufacturing Quantity are required"));
- if (this.frm.doc.work_order || this.frm.doc.bom_no) {
- // if work order / bom is mentioned, get items
+ if (
+ this.frm.doc.work_order ||
+ this.frm.doc.bom_no ||
+ (this.frm.doc.purpose === "Disassemble" && this.frm.doc.source_stock_entry)
+ ) {
return this.frm.call({
doc: me.frm.doc,
freeze: true,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json
index f37a2785252..797bb8ac5dd 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.json
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.json
@@ -11,6 +11,7 @@
"naming_series",
"stock_entry_type",
"outgoing_stock_entry",
+ "source_stock_entry",
"purpose",
"add_to_transit",
"work_order",
@@ -120,6 +121,15 @@
"options": "Stock Entry",
"read_only": 1
},
+ {
+ "depends_on": "eval:doc.purpose == 'Disassemble'",
+ "fieldname": "source_stock_entry",
+ "fieldtype": "Link",
+ "label": "Source Stock Entry (Manufacture)",
+ "no_copy": 1,
+ "options": "Stock Entry",
+ "print_hide": 1
+ },
{
"bold": 1,
"fetch_from": "stock_entry_type.purpose",
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 24e704e07b2..5f9e55cee50 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -28,7 +28,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.manufacturing.doctype.bom.bom import (
add_additional_cost,
- get_bom_items_as_dict,
get_op_cost_from_sub_assemblies,
get_scrap_items_from_sub_assemblies,
validate_bom_no,
@@ -143,6 +142,7 @@ class StockEntry(StockController):
select_print_heading: DF.Link | None
set_posting_time: DF.Check
source_address_display: DF.SmallText | None
+ source_stock_entry: DF.Link | None
source_warehouse_address: DF.Link | None
stock_entry_type: DF.Link
subcontracting_order: DF.Link | None
@@ -183,6 +183,13 @@ class StockEntry(StockController):
)
def onload(self):
+ self.update_items_from_bin_details()
+
+ def before_print(self, settings=None):
+ super().before_print(settings)
+ self.update_items_from_bin_details()
+
+ def update_items_from_bin_details(self):
for item in self.get("items"):
item.update(get_bin_details(item.item_code, item.s_warehouse))
@@ -210,6 +217,7 @@ class StockEntry(StockController):
self.validate_warehouse()
self.validate_warehouse_of_sabb()
self.validate_work_order()
+ self.validate_source_stock_entry()
self.validate_bom()
self.set_process_loss_qty()
self.validate_purchase_order()
@@ -286,6 +294,56 @@ class StockEntry(StockController):
if self.purpose != "Disassemble":
return
+ if self.get("source_stock_entry"):
+ self._set_serial_batch_for_disassembly_from_stock_entry()
+ else:
+ self._set_serial_batch_for_disassembly_from_available_materials()
+
+ def _set_serial_batch_for_disassembly_from_stock_entry(self):
+ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ get_voucher_wise_serial_batch_from_bundle,
+ )
+
+ source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty"))
+ scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0
+
+ bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry])
+ source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()}
+
+ for row in self.items:
+ if not row.ste_detail:
+ continue
+
+ source_row = source_rows_by_name.get(row.ste_detail)
+ if not source_row:
+ continue
+
+ source_warehouse = source_row.s_warehouse or source_row.t_warehouse
+ key = (source_row.item_code, source_warehouse, self.source_stock_entry)
+ source_bundle = bundle_data.get(key, {})
+
+ batches = defaultdict(float)
+ serial_nos = []
+
+ if source_bundle.get("batch_nos"):
+ qty_remaining = row.transfer_qty
+ for batch_no, batch_qty in source_bundle["batch_nos"].items():
+ if qty_remaining <= 0:
+ break
+ alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining)
+ batches[batch_no] = alloc
+ qty_remaining -= alloc
+ elif source_row.batch_no:
+ batches[source_row.batch_no] = row.transfer_qty
+
+ if source_bundle.get("serial_nos"):
+ serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)]
+ elif source_row.serial_no:
+ serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)]
+
+ self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
+
+ def _set_serial_batch_for_disassembly_from_available_materials(self):
available_materials = get_available_materials(self.work_order, self)
for row in self.items:
warehouse = row.s_warehouse or row.t_warehouse
@@ -311,34 +369,38 @@ class StockEntry(StockController):
if materials.serial_nos:
serial_nos = materials.serial_nos[: int(row.transfer_qty)]
- if not serial_nos and not batches:
- continue
+ self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
- bundle_doc = SerialBatchCreation(
- {
- "item_code": row.item_code,
- "warehouse": warehouse,
- "posting_date": self.posting_date,
- "posting_time": self.posting_time,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "voucher_detail_no": row.name,
- "qty": row.transfer_qty,
- "type_of_transaction": "Inward" if row.t_warehouse else "Outward",
- "company": self.company,
- "do_not_submit": True,
- }
- ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
+ def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches):
+ if not serial_nos and not batches:
+ return
- row.serial_and_batch_bundle = bundle_doc.name
- row.use_serial_batch_fields = 0
+ warehouse = row.s_warehouse or row.t_warehouse
+ bundle_doc = SerialBatchCreation(
+ {
+ "item_code": row.item_code,
+ "warehouse": warehouse,
+ "posting_date": self.posting_date,
+ "posting_time": self.posting_time,
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "voucher_detail_no": row.name,
+ "qty": row.transfer_qty,
+ "type_of_transaction": "Inward" if row.t_warehouse else "Outward",
+ "company": self.company,
+ "do_not_submit": True,
+ }
+ ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
- row.db_set(
- {
- "serial_and_batch_bundle": bundle_doc.name,
- "use_serial_batch_fields": 0,
- }
- )
+ row.serial_and_batch_bundle = bundle_doc.name
+ row.use_serial_batch_fields = 0
+
+ row.db_set(
+ {
+ "serial_and_batch_bundle": bundle_doc.name,
+ "use_serial_batch_fields": 0,
+ }
+ )
def on_submit(self):
self.set_serial_batch_for_disassembly()
@@ -722,7 +784,7 @@ class StockEntry(StockController):
if self.purpose == "Disassemble":
if has_bom:
- if d.is_finished_item:
+ if d.is_finished_item or d.is_scrap_item:
d.t_warehouse = None
if not d.s_warehouse:
frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
@@ -759,6 +821,36 @@ class StockEntry(StockController):
elif self.purpose != "Material Transfer":
self.work_order = None
+ def validate_source_stock_entry(self):
+ if not self.get("source_stock_entry"):
+ return
+
+ if self.work_order:
+ source_wo = frappe.db.get_value("Stock Entry", self.source_stock_entry, "work_order")
+ if source_wo and source_wo != self.work_order:
+ frappe.throw(
+ _(
+ "Source Stock Entry {0} belongs to Work Order {1}, not {2}. Please use a manufacture entry from the same Work Order."
+ ).format(self.source_stock_entry, source_wo, self.work_order),
+ title=_("Work Order Mismatch"),
+ )
+
+ from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty
+
+ available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name)
+
+ if flt(self.fg_completed_qty) > available_qty:
+ frappe.throw(
+ _(
+ "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble."
+ ).format(
+ self.fg_completed_qty,
+ self.source_stock_entry,
+ available_qty,
+ ),
+ title=_("Excess Disassembly"),
+ )
+
def check_if_operations_completed(self):
"""Check if Time Sheets are completed against before manufacturing to capture operating costs."""
prod_order = frappe.get_doc("Work Order", self.work_order)
@@ -1951,44 +2043,114 @@ class StockEntry(StockController):
)
def get_items_for_disassembly(self):
- """Get items for Disassembly Order"""
+ """Get items for Disassembly Order.
+
+ Priority:
+ 1. From a specific Manufacture Stock Entry (exact reversal)
+ 2. From Work Order Manufacture Stock Entries (averaged reversal)
+ 3. From BOM (standalone disassembly)
+ """
+
+ # Auto-set source_stock_entry if WO has exactly one manufacture entry
+ if not self.get("source_stock_entry") and self.work_order:
+ manufacture_entries = frappe.get_all(
+ "Stock Entry",
+ filters={
+ "work_order": self.work_order,
+ "purpose": "Manufacture",
+ "docstatus": 1,
+ },
+ pluck="name",
+ limit_page_length=2,
+ )
+ if len(manufacture_entries) == 1:
+ self.source_stock_entry = manufacture_entries[0]
+
+ if self.get("source_stock_entry"):
+ return self._add_items_for_disassembly_from_stock_entry()
if self.work_order:
return self._add_items_for_disassembly_from_work_order()
return self._add_items_for_disassembly_from_bom()
- def _add_items_for_disassembly_from_work_order(self):
- items = self.get_items_from_manufacture_entry()
+ def _add_items_for_disassembly_from_stock_entry(self):
+ source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
+ if not source_fg_qty:
+ frappe.throw(
+ _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry)
+ )
- s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
+ disassemble_qty = flt(self.fg_completed_qty)
+ scale_factor = disassemble_qty / flt(source_fg_qty)
- items_dict = get_bom_items_as_dict(
- self.bom_no,
- self.company,
- self.fg_completed_qty,
- fetch_exploded=self.use_multi_level_bom,
- fetch_qty_in_stock_uom=False,
+ self._append_disassembly_row_from_source(
+ disassemble_qty=disassemble_qty,
+ scale_factor=scale_factor,
)
- for row in items:
- child_row = self.append("items", {})
- for field, value in row.items():
- if value is not None:
- child_row.set(field, value)
+ def _add_items_for_disassembly_from_work_order(self):
+ wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty")
- # update qty and amount from BOM items
- bom_items = items_dict.get(row.item_code)
- if bom_items:
- child_row.qty = bom_items.get("qty", child_row.qty)
- child_row.amount = bom_items.get("amount", child_row.amount)
+ wo_produced_qty = flt(wo_produced_qty)
+ if wo_produced_qty <= 0:
+ frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order))
- if row.is_finished_item:
- child_row.qty = self.fg_completed_qty
+ disassemble_qty = flt(self.fg_completed_qty)
+ if disassemble_qty <= 0:
+ frappe.throw(_("Disassemble Qty cannot be less than or equal to 0."))
- child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
- child_row.t_warehouse = row.s_warehouse
- child_row.is_finished_item = 0 if row.is_finished_item else 1
+ scale_factor = disassemble_qty / wo_produced_qty
+
+ self._append_disassembly_row_from_source(
+ disassemble_qty=disassemble_qty,
+ scale_factor=scale_factor,
+ )
+
+ def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor):
+ for source_row in self.get_items_from_manufacture_stock_entry():
+ if source_row.is_finished_item:
+ qty = disassemble_qty
+ s_warehouse = self.from_warehouse or source_row.t_warehouse
+ t_warehouse = ""
+ elif source_row.s_warehouse:
+ # RM: was consumed FROM s_warehouse -> return TO s_warehouse
+ qty = flt(source_row.qty * scale_factor)
+ s_warehouse = ""
+ t_warehouse = self.to_warehouse or source_row.s_warehouse
+ else:
+ # Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse
+ qty = flt(source_row.qty * scale_factor)
+ s_warehouse = source_row.t_warehouse
+ t_warehouse = ""
+
+ item = {
+ "item_code": source_row.item_code,
+ "item_name": source_row.item_name,
+ "description": source_row.description,
+ "stock_uom": source_row.stock_uom,
+ "uom": source_row.uom,
+ "conversion_factor": source_row.conversion_factor,
+ "basic_rate": source_row.basic_rate,
+ "qty": qty,
+ "s_warehouse": s_warehouse,
+ "t_warehouse": t_warehouse,
+ "is_finished_item": source_row.is_finished_item,
+ "is_scrap_item": source_row.is_scrap_item,
+ "bom_no": source_row.bom_no,
+ # batch and serial bundles built on submit
+ "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0,
+ }
+
+ if self.source_stock_entry:
+ item.update(
+ {
+ "against_stock_entry": self.source_stock_entry,
+ "ste_detail": source_row.name,
+ }
+ )
+
+ self.append("items", item)
def _add_items_for_disassembly_from_bom(self):
if not self.bom_no or not self.fg_completed_qty:
@@ -2004,37 +2166,64 @@ class StockEntry(StockController):
self.add_to_stock_entry_detail(item_dict)
+ # Scrap items (reverse: take scrap FROM scrap warehouse instead of producing TO it)
+ scrap_items = self.get_bom_scrap_material(self.fg_completed_qty)
+ if scrap_items:
+ scrap_warehouse = self.from_warehouse
+ if self.work_order:
+ wo_values = frappe.db.get_value(
+ "Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True
+ )
+ scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse
+
+ for item in scrap_items.values():
+ item["from_warehouse"] = scrap_warehouse
+ item["to_warehouse"] = ""
+ item["is_finished_item"] = 0
+
+ self.add_to_stock_entry_detail(scrap_items, bom_no=self.bom_no)
+
# Finished goods
self.load_items_from_bom()
- def get_items_from_manufacture_entry(self):
- return frappe.get_all(
- "Stock Entry",
- fields=[
- "`tabStock Entry Detail`.`item_code`",
- "`tabStock Entry Detail`.`item_name`",
- "`tabStock Entry Detail`.`description`",
- "sum(`tabStock Entry Detail`.qty) as qty",
- "sum(`tabStock Entry Detail`.transfer_qty) as transfer_qty",
- "`tabStock Entry Detail`.`stock_uom`",
- "`tabStock Entry Detail`.`uom`",
- "`tabStock Entry Detail`.`basic_rate`",
- "`tabStock Entry Detail`.`conversion_factor`",
- "`tabStock Entry Detail`.`is_finished_item`",
- "`tabStock Entry Detail`.`batch_no`",
- "`tabStock Entry Detail`.`serial_no`",
- "`tabStock Entry Detail`.`s_warehouse`",
- "`tabStock Entry Detail`.`t_warehouse`",
- "`tabStock Entry Detail`.`use_serial_batch_fields`",
- ],
- filters=[
- ["Stock Entry", "purpose", "=", "Manufacture"],
- ["Stock Entry", "work_order", "=", self.work_order],
- ["Stock Entry", "docstatus", "=", 1],
- ["Stock Entry Detail", "docstatus", "=", 1],
- ],
- order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc",
- group_by="`tabStock Entry Detail`.`item_code`",
+ def get_items_from_manufacture_stock_entry(self):
+ SE = frappe.qb.DocType("Stock Entry")
+ SED = frappe.qb.DocType("Stock Entry Detail")
+ query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1)
+
+ common_fields = [
+ SED.item_code,
+ SED.item_name,
+ SED.description,
+ SED.stock_uom,
+ SED.uom,
+ SED.basic_rate,
+ SED.conversion_factor,
+ SED.is_finished_item,
+ SED.is_scrap_item,
+ SED.batch_no,
+ SED.serial_no,
+ SED.use_serial_batch_fields,
+ SED.s_warehouse,
+ SED.t_warehouse,
+ SED.bom_no,
+ ]
+
+ if self.source_stock_entry:
+ return (
+ query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields)
+ .where(SE.name == self.source_stock_entry)
+ .orderby(SED.idx)
+ .run(as_dict=True)
+ )
+
+ return (
+ query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields)
+ .where(SE.purpose == "Manufacture")
+ .where(SE.work_order == self.work_order)
+ .groupby(SED.item_code)
+ .orderby(SED.idx)
+ .run(as_dict=True)
)
@frappe.whitelist()
@@ -2396,7 +2585,7 @@ class StockEntry(StockController):
return item_dict
def get_scrap_items_from_job_card(self):
- if not self.pro_doc:
+ if not getattr(self, "pro_doc", None):
self.set_work_order_details()
if not self.pro_doc.operations:
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index a9a3fcbfae4..58ea8083087 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -73,10 +73,6 @@
"auto_indent",
"column_break_27",
"reorder_email_notify",
- "inter_warehouse_transfer_settings_section",
- "allow_from_dn",
- "column_break_31",
- "allow_from_pr",
"stock_closing_tab",
"control_historical_stock_transactions_section",
"stock_frozen_upto",
@@ -223,23 +219,6 @@
"fieldtype": "Data",
"label": "Naming Series Prefix"
},
- {
- "fieldname": "inter_warehouse_transfer_settings_section",
- "fieldtype": "Section Break",
- "label": "Inter Warehouse Transfer Settings"
- },
- {
- "default": "0",
- "fieldname": "allow_from_dn",
- "fieldtype": "Check",
- "label": "Allow Material Transfer from Delivery Note to Sales Invoice"
- },
- {
- "default": "0",
- "fieldname": "allow_from_pr",
- "fieldtype": "Check",
- "label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice"
- },
{
"description": "If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.",
"fieldname": "role_allowed_to_create_edit_back_dated_transactions",
@@ -287,10 +266,6 @@
"fieldname": "column_break_27",
"fieldtype": "Column Break"
},
- {
- "fieldname": "column_break_31",
- "fieldtype": "Column Break"
- },
{
"fieldname": "quality_inspection_settings_section",
"fieldtype": "Section Break",
@@ -553,7 +528,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-02-25 09:56:34.105949",
+ "modified": "2026-03-27 22:39:16.812184",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 69e626db1ba..c6c6bd49488 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -26,8 +26,6 @@ class StockSettings(Document):
action_if_quality_inspection_is_not_submitted: DF.Literal["Stop", "Warn"]
action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"]
allow_existing_serial_no: DF.Check
- allow_from_dn: DF.Check
- allow_from_pr: DF.Check
allow_internal_transfer_at_arms_length_price: DF.Check
allow_negative_stock: DF.Check
allow_negative_stock_for_batch: DF.Check
@@ -235,9 +233,6 @@ class StockSettings(Document):
)
)
- def on_update(self):
- self.toggle_warehouse_field_for_inter_warehouse_transfer()
-
def change_precision_for_for_sales(self):
doc_before_save = self.get_doc_before_save()
if doc_before_save and (
@@ -288,40 +283,6 @@ class StockSettings(Document):
validate_fields_for_doctype=False,
)
- def toggle_warehouse_field_for_inter_warehouse_transfer(self):
- make_property_setter(
- "Sales Invoice Item",
- "target_warehouse",
- "hidden",
- 1 - cint(self.allow_from_dn),
- "Check",
- validate_fields_for_doctype=False,
- )
- make_property_setter(
- "Delivery Note Item",
- "target_warehouse",
- "hidden",
- 1 - cint(self.allow_from_dn),
- "Check",
- validate_fields_for_doctype=False,
- )
- make_property_setter(
- "Purchase Invoice Item",
- "from_warehouse",
- "hidden",
- 1 - cint(self.allow_from_pr),
- "Check",
- validate_fields_for_doctype=False,
- )
- make_property_setter(
- "Purchase Receipt Item",
- "from_warehouse",
- "hidden",
- 1 - cint(self.allow_from_pr),
- "Check",
- validate_fields_for_doctype=False,
- )
-
def clean_all_descriptions():
for item in frappe.get_all("Item", ["name", "description"]):
diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html
index adab4786403..5a69c405364 100644
--- a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html
+++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html
@@ -32,8 +32,8 @@
class="btn btn-default btn-xs btn-edit"
style="margin: 4px 0; float: left;"
data-warehouse="{{ d.warehouse }}"
- data-item="{{ escape(d.item_code) }}"
- data-company="{{ escape(d.company) }}">
+ data-item="{{ d.item_code }}"
+ data-company="{{ d.company }}">
{{ __("Edit Capacity") }}
diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
index 808afadd05a..327f158e3f6 100644
--- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
+++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
@@ -248,12 +248,7 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
bin.warehouse,
item.valuation_method,
)
- .where(
- (item.is_stock_item == 1)
- & (item.has_serial_no == 0)
- & (warehouse.is_group == 0)
- & (warehouse.company == filters.company)
- )
+ .where((item.is_stock_item == 1) & (warehouse.is_group == 0) & (warehouse.company == filters.company))
)
if filters.item_code:
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index d50d7fc5dba..ccc65a1e329 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -4,16 +4,19 @@
import copy
import gzip
import json
+from collections import deque
import frappe
from frappe import _, bold, scrub
from frappe.model.meta import get_field_precision
+from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
from frappe.utils import (
cint,
cstr,
flt,
format_date,
+ get_datetime,
get_link_to_form,
getdate,
now,
@@ -66,8 +69,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
from erpnext.controllers.stock_controller import future_sle_exists
if sl_entries:
- cancel = sl_entries[0].get("is_cancelled")
- if cancel:
+ cancelled = sl_entries[0].get("is_cancelled")
+ if cancelled:
validate_cancellation(sl_entries)
set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
@@ -75,10 +78,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
future_sle_exists(args, sl_entries)
for sle in sl_entries:
- if sle.serial_no and not via_landed_cost_voucher:
- validate_serial_no(sle)
-
- if cancel:
+ if cancelled:
sle["actual_qty"] = -flt(sle.get("actual_qty"))
if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"):
@@ -155,35 +155,6 @@ def get_args_for_future_sle(row):
)
-def validate_serial_no(sle):
- from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
-
- for sn in get_serial_nos(sle.serial_no):
- args = copy.deepcopy(sle)
- args.serial_no = sn
- args.warehouse = ""
-
- vouchers = []
- for row in get_stock_ledger_entries(args, ">"):
- voucher_type = frappe.bold(row.voucher_type)
- voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no))
- vouchers.append(f"{voucher_type} {voucher_no}")
-
- if vouchers:
- serial_no = frappe.bold(sn)
- msg = (
- f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first.
- The list of the transactions are as below."""
- + "