mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-19 20:54:01 +00:00
Compare commits
35 Commits
v16.23.1
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0dab55fcc | ||
|
|
cb47745d8c | ||
|
|
dc9ae20db8 | ||
|
|
6cb42ab8b1 | ||
|
|
35e06045bd | ||
|
|
6c37acc180 | ||
|
|
42c121a750 | ||
|
|
1e027364e3 | ||
|
|
d2fee32eb3 | ||
|
|
21912402c0 | ||
|
|
43b355eaf6 | ||
|
|
175aac4156 | ||
|
|
30650f298b | ||
|
|
3110ab1c57 | ||
|
|
40110d83c9 | ||
|
|
dbc831e008 | ||
|
|
686437bd54 | ||
|
|
58d5f39e0a | ||
|
|
c7dbedbfdc | ||
|
|
8e21af0a63 | ||
|
|
87e498cd7d | ||
|
|
f8aa4c730c | ||
|
|
a335838691 | ||
|
|
1f075d4bbf | ||
|
|
7f441864d6 | ||
|
|
2b28b7e694 | ||
|
|
56d9cbabbf | ||
|
|
4481efec17 | ||
|
|
84a1a51023 | ||
|
|
98f45221e6 | ||
|
|
846e0a9f06 | ||
|
|
6185507614 | ||
|
|
d051407126 | ||
|
|
3d91e021a3 | ||
|
|
a6310351fd |
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.23.1"
|
||||
__version__ = "16.15.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -22,11 +22,13 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_usd_payable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
|
||||
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -50,8 +51,7 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -218,10 +218,11 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-29 11:52:33.550847",
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -17,9 +17,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -26,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -11,9 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
|
||||
function toggle_distribution_fields(frm) {
|
||||
const grid = frm.fields_dict.budget_distribution.grid;
|
||||
|
||||
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
|
||||
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
|
||||
|
||||
["amount", "percent"].forEach((field) => {
|
||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||
});
|
||||
|
||||
@@ -355,8 +355,8 @@ class Budget(Document):
|
||||
if self.should_regenerate_budget_distribution():
|
||||
return
|
||||
|
||||
total_amount = sum(d.amount for d in self.budget_distribution)
|
||||
total_percent = sum(d.percent for d in self.budget_distribution)
|
||||
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
|
||||
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
|
||||
|
||||
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||
frappe.throw(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Start Date",
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -25,26 +26,29 @@
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "End Date",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount"
|
||||
"label": "Amount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "percent",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Percent"
|
||||
"label": "Percent",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-03 13:18:28.398198",
|
||||
"modified": "2026-06-18 11:23:17.669733",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budget Distribution",
|
||||
|
||||
@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
end_date: DF.Date | None
|
||||
end_date: DF.Date
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
percent: DF.Percent
|
||||
start_date: DF.Date | None
|
||||
start_date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.set_system_and_company_settings()
|
||||
|
||||
def set_system_and_company_settings(self):
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
validate_docs_for_voucher_types,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
|
||||
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
@@ -131,6 +132,9 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
|
||||
@@ -12,10 +12,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.configure_monitoring_tool()
|
||||
self.clear_old_entries()
|
||||
|
||||
def configure_monitoring_tool(self):
|
||||
monitor_settings = frappe.get_doc("Ledger Health Monitor")
|
||||
|
||||
@@ -1206,9 +1206,9 @@ class PaymentEntry(AccountsController):
|
||||
continue
|
||||
|
||||
if tax.add_deduct_tax == "Add":
|
||||
included_taxes += tax.base_tax_amount
|
||||
included_taxes += flt(tax.base_tax_amount)
|
||||
else:
|
||||
included_taxes -= tax.base_tax_amount
|
||||
included_taxes -= flt(tax.base_tax_amount)
|
||||
|
||||
return included_taxes
|
||||
|
||||
|
||||
@@ -1113,6 +1113,27 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
def test_payment_entry_with_inclusive_tax(self):
|
||||
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||
payment_entry = create_payment_entry(paid_amount=1180)
|
||||
payment_entry.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"charge_type": "On Paid Amount",
|
||||
"rate": 18,
|
||||
"included_in_paid_amount": 1,
|
||||
"add_deduct_tax": "Add",
|
||||
"description": "Service Tax",
|
||||
},
|
||||
)
|
||||
payment_entry.save()
|
||||
payment_entry.submit()
|
||||
|
||||
# 1180 incl 18% => 1000 base + 180 tax
|
||||
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||
|
||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Payment Ledger"
|
||||
|
||||
@@ -21,10 +21,8 @@ class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
|
||||
letterhead.is_default = 0
|
||||
letterhead.save()
|
||||
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.company = "_Test Company"
|
||||
self.create_customer(customer_name="Other Customer")
|
||||
self.clear_old_entries()
|
||||
self.si = create_sales_invoice()
|
||||
create_sales_invoice(customer="Other Customer")
|
||||
|
||||
|
||||
@@ -16,12 +16,14 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_supplier()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
@@ -372,7 +374,6 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(so.advance_paid, 0)
|
||||
|
||||
def test_06_unreconcile_advance_from_payment_entry(self):
|
||||
self.enable_advance_as_liability()
|
||||
so1 = self.create_sales_order()
|
||||
so2 = self.create_sales_order()
|
||||
|
||||
@@ -423,7 +424,11 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.disable_advance_as_liability()
|
||||
|
||||
def test_07_adv_from_so_to_invoice(self):
|
||||
self.enable_advance_as_liability()
|
||||
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
|
||||
)
|
||||
|
||||
so = self.create_sales_order()
|
||||
pe = self.create_payment_entry()
|
||||
pe.paid_amount = 1000
|
||||
|
||||
@@ -821,13 +821,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
|
||||
@@ -9,11 +9,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_supplier(currency="USD", supplier_name="Test Supplier2")
|
||||
self.create_usd_payable_account()
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.supplier = "_Test Supplier 2"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
|
||||
def test_accounts_payable_for_foreign_currency_supplier(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
|
||||
@@ -12,11 +12,17 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_usd_receivable_account()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
|
||||
def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False, **args):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -11,10 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def test_01_receivable_summary_output(self):
|
||||
"""
|
||||
|
||||
@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
|
||||
budget_distributions = get_budget_distributions(budget)
|
||||
|
||||
for row in budget_distributions:
|
||||
if not row.start_date or not row.end_date:
|
||||
continue
|
||||
|
||||
months = get_months_in_range(row.start_date, row.end_date)
|
||||
if not months:
|
||||
continue
|
||||
|
||||
monthly_budget = flt(row.amount) / len(months)
|
||||
|
||||
for month_date in months:
|
||||
|
||||
@@ -12,10 +12,12 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False, **args):
|
||||
si = create_sales_invoice(
|
||||
|
||||
@@ -61,11 +61,16 @@ class TestDeferredRevenueAndExpense(ERPNextTestSuite, AccountsTestMixin):
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer("_Test Customer")
|
||||
self.create_supplier("_Test Furniture Supplier")
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.setup_deferred_accounts_and_items()
|
||||
self.clear_old_entries()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"})
|
||||
def test_deferred_revenue(self):
|
||||
|
||||
@@ -12,7 +12,13 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGeneralAndPaymentLedger(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.company = "_Test Company"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -14,7 +14,6 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestGeneralLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.clear_old_entries()
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
|
||||
@@ -18,8 +18,6 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.create_item()
|
||||
self.create_bundle()
|
||||
self.create_customer()
|
||||
self.create_sales_invoice()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Gross Profit"
|
||||
|
||||
@@ -9,9 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWisePurchaseRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_supplier()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
pi = make_purchase_invoice(
|
||||
|
||||
@@ -9,9 +9,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
|
||||
@@ -14,9 +14,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def create_sales_invoice(self, qty=1, rate=150, no_payment_schedule=False, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -10,9 +10,13 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.create_child_cost_center()
|
||||
|
||||
def create_child_cost_center(self):
|
||||
|
||||
@@ -9,10 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestSupplierLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_supplier()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -20,8 +20,7 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
create_records()
|
||||
|
||||
def test_tax_withholding_for_customers(self):
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.utilities.product import get_item_codes_by_attributes
|
||||
@@ -129,6 +130,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
|
||||
)
|
||||
|
||||
|
||||
def get_attribute_value_renames(item_attribute):
|
||||
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
|
||||
if item_attribute.numeric_values:
|
||||
return {}
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return {}
|
||||
|
||||
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
|
||||
renames = {}
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_values and old_values[row.name] != row.attribute_value:
|
||||
renames[old_values[row.name]] = row.attribute_value
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def update_variant_attribute_values(item_attribute):
|
||||
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
|
||||
value_map = get_attribute_value_renames(item_attribute)
|
||||
if not value_map:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
attribute_value = item_variant_table.attribute_value
|
||||
attribute_value_case = Case()
|
||||
|
||||
for old_value, new_value in value_map.items():
|
||||
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
|
||||
|
||||
(
|
||||
frappe.qb.update(item_variant_table)
|
||||
.join(item_table)
|
||||
.on(item_table.name == item_variant_table.parent)
|
||||
.set(attribute_value, attribute_value_case.else_(attribute_value))
|
||||
.where(item_table.variant_of.isnotnull())
|
||||
.where(item_table.variant_of != "")
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(attribute_value.isin(list(value_map)))
|
||||
).run()
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -445,6 +445,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
doc.pricing_rules = []
|
||||
doc.return_against = source.name
|
||||
doc.set_warehouse = ""
|
||||
if doctype == "Sales Invoice":
|
||||
doc.is_debit_note = 0
|
||||
if doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
doc.is_pos = source.is_pos
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ from erpnext.controllers.sales_and_purchase_return import (
|
||||
filter_serial_batches,
|
||||
make_serial_batch_bundle_for_return,
|
||||
)
|
||||
|
||||
# Re-exported for backward compatibility; canonical home is erpnext.exceptions.
|
||||
from erpnext.exceptions import (
|
||||
BatchExpiredError,
|
||||
QualityInspectionNotSubmittedError,
|
||||
QualityInspectionRejectedError,
|
||||
QualityInspectionRequiredError,
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
@@ -37,22 +45,6 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
from erpnext.stock.stock_ledger import get_items_to_be_repost
|
||||
|
||||
|
||||
class QualityInspectionRequiredError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class QualityInspectionRejectedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class QualityInspectionNotSubmittedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class BatchExpiredError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class StockController(AccountsController):
|
||||
def validate(self):
|
||||
super().validate()
|
||||
@@ -2163,7 +2155,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
|
||||
|
||||
inspection_fieldname = inspection_fieldname_map.get(doctype)
|
||||
if inspection_fieldname is None:
|
||||
return []
|
||||
return items if doctype == "Stock Entry" else []
|
||||
|
||||
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
|
||||
@@ -743,7 +743,14 @@ class SubcontractingInwardController:
|
||||
"name": ["in", list(data.keys())],
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["rate", "name", "required_qty", "received_qty"],
|
||||
fields=[
|
||||
"rate",
|
||||
"name",
|
||||
"required_qty",
|
||||
"received_qty",
|
||||
"returned_qty",
|
||||
"consumed_qty",
|
||||
],
|
||||
)
|
||||
|
||||
doc_updates = {}
|
||||
@@ -751,13 +758,17 @@ class SubcontractingInwardController:
|
||||
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
|
||||
current_rate = flt(data[d.name].rate)
|
||||
|
||||
# Calculate weighted average rate
|
||||
old_total = d.rate * d.received_qty
|
||||
# Weighted average rate must be computed on the on-hand balance
|
||||
balance_qty = d.received_qty - d.returned_qty - d.consumed_qty
|
||||
old_total = d.rate * balance_qty
|
||||
current_total = current_rate * current_qty
|
||||
|
||||
new_balance_qty = balance_qty + current_qty
|
||||
d.received_qty = d.received_qty + current_qty
|
||||
d.rate = (
|
||||
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
|
||||
flt((old_total + current_total) / new_balance_qty, precision)
|
||||
if new_balance_qty > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
if not d.required_qty and not d.received_qty:
|
||||
|
||||
@@ -28,3 +28,20 @@ class MandatoryAccountDimensionError(frappe.ValidationError):
|
||||
|
||||
class ReportingCurrencyExchangeNotFoundError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
# stock
|
||||
class QualityInspectionRequiredError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class QualityInspectionRejectedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class QualityInspectionNotSubmittedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class BatchExpiredError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
@@ -483,3 +483,4 @@ erpnext.patches.v16_0.fix_titles
|
||||
erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from frappe.database.utils import drop_index_if_exists
|
||||
|
||||
|
||||
def execute():
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "serial_no")
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "warehouse")
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "type_of_transaction")
|
||||
@@ -389,11 +389,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
);
|
||||
}
|
||||
|
||||
const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(
|
||||
this.frm.doc.doctype
|
||||
)
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
const inspection_type =
|
||||
incoming_doctypes.includes(this.frm.doc.doctype) ||
|
||||
(this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
|
||||
let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (row) {
|
||||
@@ -2885,11 +2887,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
];
|
||||
|
||||
const me = this;
|
||||
const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(
|
||||
this.frm.doc.doctype
|
||||
)
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
const inspection_type =
|
||||
incoming_doctypes.includes(this.frm.doc.doctype) ||
|
||||
(this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Select Items for Quality Inspection"),
|
||||
size: "extra-large",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"stock_uom",
|
||||
"expiry_date",
|
||||
"use_batchwise_valuation",
|
||||
"allow_negative_stock_for_batch",
|
||||
"disabled",
|
||||
"source",
|
||||
"column_break_9",
|
||||
@@ -199,6 +200,14 @@
|
||||
{
|
||||
"fieldname": "column_break_xrll",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow negative stock entries for this batch, overriding the 'Allow negative stock for Batch' setting in Stock Settings. This may lead to incorrect valuation rates, so it is recommended to avoid using this option.",
|
||||
"fieldname": "allow_negative_stock_for_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Negative Stock for Batch",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-archive",
|
||||
@@ -206,7 +215,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2026-06-16 16:01:26.556324",
|
||||
"modified": "2026-06-17 16:01:26.556324",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Batch",
|
||||
|
||||
@@ -94,6 +94,7 @@ class Batch(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
allow_negative_stock_for_batch: DF.Check
|
||||
batch_id: DF.Data
|
||||
batch_qty: DF.Float
|
||||
description: DF.SmallText | None
|
||||
|
||||
@@ -410,6 +410,89 @@ class TestItem(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
|
||||
|
||||
def test_rename_attribute_value_updates_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.attribute_value = "Larger"
|
||||
break
|
||||
|
||||
def restore_test_size_large():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Larger":
|
||||
row.attribute_value = "Large"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_large)
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Larger",
|
||||
)
|
||||
|
||||
def test_swapped_attribute_value_renames_update_variants(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-S", force=1)
|
||||
|
||||
large_variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
large_variant.save()
|
||||
|
||||
small_variant = create_variant("_Test Variant Item", {"Test Size": "Small"})
|
||||
small_variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
original_values = {row.name: row.attribute_value for row in attribute.item_attribute_values}
|
||||
|
||||
def restore_test_size_values():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
row.attribute_value = original_values[row.name]
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_values)
|
||||
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.attribute_value = "Small"
|
||||
elif row.attribute_value == "Small":
|
||||
row.attribute_value = "Large"
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": large_variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Small",
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Item Variant Attribute",
|
||||
{"parent": small_variant.name, "attribute": "Test Size"},
|
||||
"attribute_value",
|
||||
),
|
||||
"Large",
|
||||
)
|
||||
|
||||
def test_make_item_variant(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.item_variant import (
|
||||
InvalidItemAttributeValueError,
|
||||
update_variant_attribute_values,
|
||||
validate_is_incremental,
|
||||
validate_item_attribute_value,
|
||||
)
|
||||
@@ -44,6 +45,7 @@ class ItemAttribute(Document):
|
||||
self.validate_duplication()
|
||||
|
||||
def on_update(self):
|
||||
update_variant_attribute_values(self)
|
||||
self.validate_exising_items()
|
||||
self.set_enabled_disabled_in_items()
|
||||
|
||||
|
||||
@@ -1143,6 +1143,52 @@ class TestMaterialRequest(ERPNextTestSuite):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
def test_mr_status_for_mixed_direct_and_transit_transfer(self):
|
||||
material_request = make_material_request(
|
||||
material_request_type="Material Transfer",
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
qty=5,
|
||||
)
|
||||
|
||||
in_transit_wh = get_in_transit_warehouse(material_request.company)
|
||||
|
||||
# Make stock available
|
||||
self._insert_stock_entry(20.0, 20.0)
|
||||
|
||||
# Direct Transfer for 3 Qty
|
||||
direct_transfer = make_stock_entry(material_request.name)
|
||||
direct_transfer.items[0].update(
|
||||
{
|
||||
"qty": 3,
|
||||
"transfer_qty": 3,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
direct_transfer.save()
|
||||
direct_transfer.submit()
|
||||
|
||||
# In Transit Transfer for remaining 2 Qty
|
||||
transit_transfer = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
transit_transfer.items[0].update(
|
||||
{
|
||||
"qty": 2,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
transit_transfer.save()
|
||||
transit_transfer.submit()
|
||||
|
||||
# Complete End Transit
|
||||
end_transit = make_stock_in_entry(transit_transfer.name)
|
||||
end_transit.save()
|
||||
end_transit.submit()
|
||||
|
||||
material_request.reload()
|
||||
|
||||
self.assertEqual(material_request.per_ordered, 100)
|
||||
self.assertEqual(material_request.status, "Transferred")
|
||||
self.assertEqual(material_request.transfer_status, "Completed")
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
@@ -1581,7 +1581,7 @@ class SerialandBatchBundle(Document):
|
||||
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
|
||||
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||
if allow_negative_stock_for_batch(batch_no):
|
||||
return
|
||||
|
||||
date_msg = ""
|
||||
@@ -1592,7 +1592,7 @@ class SerialandBatchBundle(Document):
|
||||
"""
|
||||
The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}.
|
||||
Please add a stock quantity of {4} to proceed with this entry.
|
||||
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed.
|
||||
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in the batch {0} or in the Stock Settings to proceed.
|
||||
However, enabling this setting may lead to negative stock in the system.
|
||||
So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."""
|
||||
).format(
|
||||
@@ -2188,6 +2188,19 @@ def combine_datetime(date, time=None):
|
||||
return get_combine_datetime(date, time)
|
||||
|
||||
|
||||
def allow_negative_stock_for_batch(batch_no):
|
||||
"""Return whether negative stock is allowed for the given batch.
|
||||
|
||||
The batch-level setting takes priority: if `allow_negative_stock_for_batch`
|
||||
is enabled on the Batch, negative stock is allowed regardless of Stock Settings.
|
||||
Otherwise, fall back to the `allow_negative_stock_for_batch` Stock Setting.
|
||||
"""
|
||||
if batch_no and frappe.db.get_value("Batch", batch_no, "allow_negative_stock_for_batch"):
|
||||
return True
|
||||
|
||||
return bool(frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"))
|
||||
|
||||
|
||||
def get_batch(item_code):
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Serial No",
|
||||
"options": "Serial No",
|
||||
"search_index": 1
|
||||
"options": "Serial No"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.has_batch_no == 1",
|
||||
@@ -62,8 +61,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Warehouse",
|
||||
"options": "Warehouse",
|
||||
"search_index": 1
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
@@ -178,8 +176,7 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Type of Transaction",
|
||||
"no_copy": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_eykr",
|
||||
|
||||
@@ -42,3 +42,4 @@ class SerialandBatchEntry(Document):
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index("Serial and Batch Entry", ["warehouse", "batch_no", "posting_datetime"])
|
||||
frappe.db.add_index("Serial and Batch Entry", ["warehouse", "serial_no", "posting_datetime"])
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
cur_frm.add_fetch("customer", "customer_name", "customer_name");
|
||||
cur_frm.add_fetch("supplier", "supplier_name", "supplier_name");
|
||||
|
||||
cur_frm.add_fetch("item_code", "item_name", "item_name");
|
||||
cur_frm.add_fetch("item_code", "description", "description");
|
||||
cur_frm.add_fetch("item_code", "item_group", "item_group");
|
||||
cur_frm.add_fetch("item_code", "brand", "brand");
|
||||
|
||||
cur_frm.cscript.onload = function () {
|
||||
cur_frm.set_query("item_code", function () {
|
||||
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
|
||||
});
|
||||
};
|
||||
|
||||
frappe.ui.form.on("Serial No", "refresh", function (frm) {
|
||||
frm.toggle_enable("item_code", frm.doc.__islocal);
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Serial No", {
|
||||
setup(frm) {
|
||||
frm.add_fetch("customer", "customer_name", "customer_name");
|
||||
frm.add_fetch("supplier", "supplier_name", "supplier_name");
|
||||
frm.add_fetch("item_code", "item_name", "item_name");
|
||||
frm.add_fetch("item_code", "description", "description");
|
||||
frm.add_fetch("item_code", "item_group", "item_group");
|
||||
frm.add_fetch("item_code", "brand", "brand");
|
||||
|
||||
frm.set_query("item_code", function () {
|
||||
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
|
||||
});
|
||||
|
||||
frm.set_query("work_order", () => {
|
||||
return {
|
||||
filters: {
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh(frm) {
|
||||
frm.toggle_enable("item_code", frm.doc.__islocal);
|
||||
frm.trigger("view_ledgers");
|
||||
},
|
||||
|
||||
|
||||
@@ -216,10 +216,11 @@ frappe.ui.form.on("Stock Entry", {
|
||||
}
|
||||
|
||||
let quality_inspection_field = frm.get_docfield("items", "quality_inspection");
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (row) {
|
||||
if (frm.is_new()) return {};
|
||||
return {
|
||||
inspection_type: "Incoming",
|
||||
inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing",
|
||||
reference_type: frm.doc.doctype,
|
||||
reference_name: frm.doc.name,
|
||||
child_row_reference: row.doc.name,
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.purpose === \"Manufacture\"",
|
||||
"fieldname": "inspection_required",
|
||||
"fieldtype": "Check",
|
||||
"label": "Inspection Required"
|
||||
@@ -769,7 +768,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-04 19:03:23.426082",
|
||||
"modified": "2026-06-11 18:23:12.340065",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry",
|
||||
|
||||
@@ -4646,13 +4646,19 @@ def get_batchwise_serial_nos(item_code, row):
|
||||
|
||||
|
||||
def get_transferred_qty(material_request):
|
||||
from pypika import Case
|
||||
|
||||
se = DocType("Stock Entry")
|
||||
sed = DocType("Stock Entry Detail")
|
||||
completed_qty = Case().when(se.add_to_transit == 1, sed.transferred_qty).else_(sed.transfer_qty)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sed)
|
||||
.inner_join(se)
|
||||
.on(se.name == sed.parent)
|
||||
.select(
|
||||
Sum(sed.transfer_qty).as_("transfer_qty"),
|
||||
Sum(sed.transferred_qty).as_("transferred_qty"),
|
||||
Sum(completed_qty).as_("transferred_qty"),
|
||||
)
|
||||
.where((sed.material_request == material_request) & (sed.docstatus == 1))
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -1089,6 +1089,316 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
repack.insert()
|
||||
self.assertRaises(frappe.ValidationError, repack.submit)
|
||||
|
||||
def test_check_item_quality_inspection_returns_items_for_stock_entry(self):
|
||||
from erpnext.controllers.stock_controller import check_item_quality_inspection
|
||||
|
||||
items = [
|
||||
{"item_code": "_Test Item", "qty": 1},
|
||||
{"item_code": "_Test Item Home Desktop 100", "qty": 1},
|
||||
]
|
||||
|
||||
se_result = check_item_quality_inspection("Stock Entry", 0, items)
|
||||
self.assertEqual(len(se_result), 2)
|
||||
|
||||
# a doctype not in INSPECTION_FIELDNAME_MAP and not a Stock Entry returns nothing
|
||||
self.assertEqual(check_item_quality_inspection("Material Request", 0, items), [])
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
|
||||
def test_quality_inspection_across_stock_entry_purposes(self):
|
||||
from erpnext.controllers.stock_controller import check_item_quality_inspection
|
||||
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection,
|
||||
)
|
||||
|
||||
item_code = "_Test Item For QI Purposes"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
create_item(item_code, is_stock_item=1)
|
||||
|
||||
s_wh = "Stores - _TC"
|
||||
t_wh = "_Test Warehouse - _TC"
|
||||
# stock the source warehouse for transfer / issue purposes
|
||||
make_stock_entry(item_code=item_code, target=s_wh, qty=100, basic_rate=100)
|
||||
|
||||
# purpose -> warehouses for the moved row; inward (with target) requires QI
|
||||
purposes = {
|
||||
"Material Receipt": {"to_warehouse": t_wh},
|
||||
"Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh},
|
||||
"Material Issue": {"from_warehouse": s_wh},
|
||||
}
|
||||
|
||||
for purpose, warehouses in purposes.items():
|
||||
with self.subTest(purpose=purpose):
|
||||
needs_qi = "to_warehouse" in warehouses
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=5,
|
||||
basic_rate=100,
|
||||
purpose=purpose,
|
||||
inspection_required=True,
|
||||
do_not_submit=True,
|
||||
**warehouses,
|
||||
)
|
||||
|
||||
# QI can be created from the Stock Entry for any purpose
|
||||
allowed = check_item_quality_inspection("Stock Entry", 0, se.as_dict().get("items"))
|
||||
self.assertTrue(any(row.get("item_code") == item_code for row in allowed))
|
||||
|
||||
if not needs_qi:
|
||||
# outward-only entry: QI is not enforced
|
||||
se.submit()
|
||||
self.assertEqual(se.docstatus, 1)
|
||||
continue
|
||||
|
||||
# inward entry without QI must block submission
|
||||
self.assertRaises(QualityInspectionRequiredError, se.submit)
|
||||
|
||||
# a rejected QI must also block submission
|
||||
se_rej = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=5,
|
||||
basic_rate=100,
|
||||
purpose=purpose,
|
||||
inspection_required=True,
|
||||
do_not_submit=True,
|
||||
**warehouses,
|
||||
)
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=se_rej.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Incoming",
|
||||
status="Rejected",
|
||||
)
|
||||
se_rej.reload()
|
||||
self.assertRaises(QualityInspectionRejectedError, se_rej.submit)
|
||||
|
||||
# a submitted, accepted QI links itself to the inward row; submission then succeeds
|
||||
se_ok = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=5,
|
||||
basic_rate=100,
|
||||
purpose=purpose,
|
||||
inspection_required=True,
|
||||
do_not_submit=True,
|
||||
**warehouses,
|
||||
)
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=se_ok.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Incoming",
|
||||
status="Accepted",
|
||||
)
|
||||
se_ok.reload()
|
||||
se_ok.submit()
|
||||
self.assertEqual(se_ok.docstatus, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
|
||||
def test_quality_inspection_required_for_manufacture(self):
|
||||
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_wo_stock_entry,
|
||||
)
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection,
|
||||
)
|
||||
|
||||
wo = make_wo_order_test_record(qty=1)
|
||||
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
|
||||
make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
|
||||
)
|
||||
|
||||
# transfer raw materials to WIP (no inspection on the transfer)
|
||||
transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
|
||||
for d in transfer.get("items"):
|
||||
d.s_warehouse = "Stores - _TC"
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
# manufacture with inspection required
|
||||
mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
|
||||
mfg.inspection_required = 1
|
||||
mfg.insert()
|
||||
self.assertRaises(QualityInspectionRequiredError, mfg.submit)
|
||||
|
||||
# a rejected QI on the finished-good row must also block submission
|
||||
qi = create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=mfg.name,
|
||||
item_code=wo.production_item,
|
||||
inspection_type="Incoming",
|
||||
status="Rejected",
|
||||
)
|
||||
mfg.reload()
|
||||
self.assertRaises(QualityInspectionRejectedError, mfg.submit)
|
||||
|
||||
# accepting the QI then allows submission
|
||||
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
|
||||
mfg.reload()
|
||||
mfg.submit()
|
||||
self.assertEqual(mfg.docstatus, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
|
||||
def test_quality_inspection_required_for_material_transfer_for_manufacture(self):
|
||||
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_wo_stock_entry,
|
||||
)
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection,
|
||||
)
|
||||
|
||||
wo = make_wo_order_test_record(qty=1)
|
||||
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
|
||||
make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
|
||||
)
|
||||
|
||||
transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
|
||||
for d in transfer.get("items"):
|
||||
d.s_warehouse = "Stores - _TC"
|
||||
transfer.inspection_required = 1
|
||||
transfer.insert()
|
||||
self.assertRaises(QualityInspectionRequiredError, transfer.submit)
|
||||
|
||||
# a rejected QI on any row moved into WIP must block submission;
|
||||
# every raw-material row moved into WIP needs a QI
|
||||
qis = []
|
||||
for item_code in {d.item_code for d in transfer.items if d.t_warehouse}:
|
||||
qis.append(
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=transfer.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Incoming",
|
||||
status="Rejected",
|
||||
)
|
||||
)
|
||||
transfer.reload()
|
||||
self.assertRaises(QualityInspectionRejectedError, transfer.submit)
|
||||
|
||||
# accepting every QI then allows submission
|
||||
for qi in qis:
|
||||
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
|
||||
transfer.reload()
|
||||
transfer.submit()
|
||||
self.assertEqual(transfer.docstatus, 1)
|
||||
|
||||
def test_quality_inspection_required_for_send_to_subcontractor(self):
|
||||
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
get_subcontracting_order,
|
||||
make_service_item,
|
||||
)
|
||||
from erpnext.exceptions import QualityInspectionRequiredError
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection,
|
||||
)
|
||||
|
||||
make_service_item("Subcontracted Service Item 1")
|
||||
sco = get_subcontracting_order(
|
||||
service_items=[
|
||||
{
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_code": "Subcontracted Service Item 1",
|
||||
"qty": 10,
|
||||
"rate": 500,
|
||||
"fg_item": "_Test FG Item",
|
||||
"fg_item_qty": 10,
|
||||
}
|
||||
]
|
||||
)
|
||||
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100)
|
||||
make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=100, basic_rate=100
|
||||
)
|
||||
|
||||
se = frappe.get_doc(make_rm_stock_entry(sco.name))
|
||||
se.from_warehouse = "_Test Warehouse - _TC"
|
||||
se.to_warehouse = "_Test Warehouse - _TC"
|
||||
se.stock_entry_type = "Send to Subcontractor"
|
||||
se.inspection_required = 1
|
||||
se.insert()
|
||||
self.assertRaises(QualityInspectionRequiredError, se.submit)
|
||||
|
||||
for item_code in {row.item_code for row in se.items if row.t_warehouse}:
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=se.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Outgoing",
|
||||
status="Accepted",
|
||||
)
|
||||
se.reload()
|
||||
se.submit()
|
||||
self.assertEqual(se.docstatus, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
|
||||
def test_quality_inspection_required_for_disassemble(self):
|
||||
from erpnext.exceptions import QualityInspectionRejectedError, QualityInspectionRequiredError
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_wo_stock_entry,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection,
|
||||
)
|
||||
|
||||
source_warehouse = "Stores - _TC"
|
||||
fg_item = make_item("Test Disassemble FG QI", {"is_stock_item": 1}).name
|
||||
raw_materials = ["Test Disassemble RM QI 1", "Test Disassemble RM QI 2"]
|
||||
for item in raw_materials:
|
||||
make_item(item, {"is_stock_item": 1})
|
||||
make_stock_entry(item_code=item, target=source_warehouse, qty=5, basic_rate=100)
|
||||
|
||||
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=fg_item, qty=1, source_warehouse=source_warehouse, skip_transfer=1
|
||||
)
|
||||
|
||||
# manufacture the FG so there is something to disassemble
|
||||
mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
|
||||
for row in mfg.items:
|
||||
if row.item_code in raw_materials:
|
||||
row.s_warehouse = source_warehouse
|
||||
mfg.submit()
|
||||
|
||||
# disassemble with inspection required -> the component rows need a QI
|
||||
dis = frappe.get_doc(make_wo_stock_entry(wo.name, "Disassemble", 1))
|
||||
dis.inspection_required = 1
|
||||
dis.insert()
|
||||
self.assertRaises(QualityInspectionRequiredError, dis.submit)
|
||||
|
||||
# a rejected QI on any disassembled component row must also block submission
|
||||
qis = []
|
||||
for item_code in {row.item_code for row in dis.items if row.t_warehouse}:
|
||||
qis.append(
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
reference_name=dis.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Outgoing",
|
||||
status="Rejected",
|
||||
)
|
||||
)
|
||||
dis.reload()
|
||||
self.assertRaises(QualityInspectionRejectedError, dis.submit)
|
||||
|
||||
# accepting every QI then allows submission
|
||||
for qi in qis:
|
||||
frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
|
||||
dis.reload()
|
||||
dis.submit()
|
||||
self.assertEqual(dis.docstatus, 1)
|
||||
|
||||
def test_customer_provided_parts_se(self):
|
||||
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
|
||||
se = make_stock_entry(
|
||||
|
||||
@@ -358,7 +358,7 @@ class FIFOSlots:
|
||||
if row.voucher_type != "Stock Reconciliation":
|
||||
return
|
||||
|
||||
if not row.batch_no or row.serial_no or row.serial_and_batch_bundle:
|
||||
if row.has_serial_no and (not row.batch_no or row.serial_no or row.serial_and_batch_bundle):
|
||||
if row.voucher_detail_no in self.stock_reco_voucher_wise_count:
|
||||
# Legacy reconciliation with a single SLE has qty_after_transaction and
|
||||
# stock_value_difference without an outward entry, so reset the queue first.
|
||||
@@ -1065,6 +1065,7 @@ class FIFOSlots:
|
||||
(doctype.voucher_type == "Stock Reconciliation")
|
||||
& (doctype.docstatus < 2)
|
||||
& (doctype.is_cancelled == 0)
|
||||
& (item.has_serial_no == 1)
|
||||
)
|
||||
.groupby(doctype.voucher_detail_no)
|
||||
)
|
||||
|
||||
@@ -191,6 +191,67 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 20.0)
|
||||
|
||||
def test_non_serial_stock_reco_decrease_preserves_ageing(self):
|
||||
"""
|
||||
Non-serial stock reconciliation should adjust FIFO by the balance delta.
|
||||
Decreasing stock consumes old slots; increasing stock adds only the new qty.
|
||||
"""
|
||||
|
||||
def make_sle(
|
||||
posting_date,
|
||||
voucher_type,
|
||||
voucher_no,
|
||||
actual_qty,
|
||||
qty_after,
|
||||
voucher_detail_no=None,
|
||||
stock_value_difference=None,
|
||||
):
|
||||
stock_value_difference = actual_qty if stock_value_difference is None else stock_value_difference
|
||||
|
||||
return frappe._dict(
|
||||
name="Flask Item",
|
||||
item_name="Flask Item",
|
||||
description="Flask Item",
|
||||
item_group=None,
|
||||
brand=None,
|
||||
stock_uom="Nos",
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=1,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type=voucher_type,
|
||||
voucher_no=voucher_no,
|
||||
voucher_detail_no=voucher_detail_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=False,
|
||||
serial_no=None,
|
||||
batch_no=None,
|
||||
serial_and_batch_bundle=None,
|
||||
)
|
||||
|
||||
filters = frappe._dict(company="_Test Company", to_date="2026-02-15", ranges=["30", "60", "90"])
|
||||
sle = [
|
||||
make_sle("2025-11-30", "Stock Entry", "001", 100, 100),
|
||||
make_sle("2025-12-31", "Stock Reconciliation", "002", 0, 60, "SRI-DECREASE", -40),
|
||||
make_sle("2026-01-31", "Stock Reconciliation", "003", 0, 90, "SRI-INCREASE", 30),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(filters, sle)
|
||||
|
||||
def prepare_stock_reco_voucher_wise_count():
|
||||
fifo_slots.stock_reco_voucher_wise_count = frappe._dict({"SRI-DECREASE": 100, "SRI-INCREASE": 60})
|
||||
|
||||
fifo_slots.prepare_stock_reco_voucher_wise_count = prepare_stock_reco_voucher_wise_count
|
||||
|
||||
slots = fifo_slots.generate()
|
||||
queue = slots["Flask Item"]["fifo_queue"]
|
||||
report_data = format_report_data(filters, slots, filters.to_date)
|
||||
|
||||
self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]])
|
||||
self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0])
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
|
||||
@@ -88,6 +88,46 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
|
||||
self.assertEqual(received_item.received_qty, 5)
|
||||
self.assertEqual(received_item.rate, 10)
|
||||
|
||||
def test_customer_provided_item_rate_with_return_between_receipts(self):
|
||||
"""Weight the average rate on the on-hand balance, not gross received_qty.
|
||||
|
||||
Receive 10 @ 100, return 5, receive 6 @ 130:
|
||||
balance-weighted (correct) = (5 * 100 + 6 * 130) / 11 = 116.36
|
||||
gross-weighted (wrong) = (10 * 100 + 6 * 130) / 16 = 111.25
|
||||
"""
|
||||
so, scio = create_so_scio()
|
||||
rm_item = "Basic RM"
|
||||
|
||||
def receive(qty, rate):
|
||||
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
|
||||
rm_in.items = [item for item in rm_in.items if item.item_code == rm_item]
|
||||
rm_in.items[0].qty = qty
|
||||
rm_in.items[0].transfer_qty = qty
|
||||
rm_in.items[0].basic_rate = rate
|
||||
rm_in.submit()
|
||||
scio.reload()
|
||||
|
||||
# Receipt 1: 10 @ 100
|
||||
receive(10, 100)
|
||||
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
|
||||
self.assertEqual(received_item.rate, 100)
|
||||
|
||||
# Return 5 to the customer
|
||||
rm_return = frappe.new_doc("Stock Entry").update(scio.make_rm_return())
|
||||
rm_return.items = [item for item in rm_return.items if item.item_code == rm_item]
|
||||
rm_return.items[0].qty = 5
|
||||
rm_return.items[0].transfer_qty = 5
|
||||
rm_return.submit()
|
||||
scio.reload()
|
||||
|
||||
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
|
||||
self.assertEqual(received_item.returned_qty, 5)
|
||||
|
||||
# Receipt 2: 6 @ 130 — must weight against the balance of 5, not gross 10
|
||||
receive(6, 130)
|
||||
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
|
||||
self.assertAlmostEqual(received_item.rate, (5 * 100 + 6 * 130) / 11, places=2)
|
||||
|
||||
def test_add_extra_customer_provided_item(self):
|
||||
so, scio = create_so_scio()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user