mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-21 13:44:03 +00:00
Compare commits
52 Commits
v16.23.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9954bb62 | ||
|
|
3f53af8b1f | ||
|
|
9469889bd5 | ||
|
|
6e61ee8d70 | ||
|
|
dcf076aad6 | ||
|
|
edd18fd650 | ||
|
|
62209348a4 | ||
|
|
537225494c | ||
|
|
20b14395e3 | ||
|
|
6ac699d3bb | ||
|
|
e6e5591088 | ||
|
|
635c51acf7 | ||
|
|
e605675e11 | ||
|
|
bf58393fda | ||
|
|
88ce356d62 | ||
|
|
396feadace | ||
|
|
c0dab55fcc | ||
|
|
cb47745d8c | ||
|
|
f4b827cb3d | ||
|
|
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 |
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -1078,7 +1078,7 @@ def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_v
|
||||
@frappe.whitelist()
|
||||
def get_linked_payments(
|
||||
bank_transaction_name: str | int,
|
||||
document_types: list[str] | None = None,
|
||||
document_types: str | list[str] | None = None,
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,7 +75,10 @@ def validate_company(company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_coa(file_name, company):
|
||||
frappe.only_for("Accounts Manager")
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
@@ -451,6 +454,7 @@ def unset_existing_data(company):
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
|
||||
frappe.db.set_value("Company", company, update_values, update_values)
|
||||
|
||||
# remove accounts data from various doctypes
|
||||
@@ -462,8 +466,7 @@ def unset_existing_data(company):
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
frappe.qb.from_(dt).where(dt.company == company).delete().run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -616,6 +616,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
def get_account_details(
|
||||
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
|
||||
):
|
||||
if not account:
|
||||
return
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not (company and posting_date):
|
||||
frappe.throw(_("Company and Posting Date is mandatory"))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1433,6 +1433,10 @@ class PurchaseInvoice(BuyingController):
|
||||
# tax table gl entries
|
||||
valuation_tax = {}
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
|
||||
capitalized_valuation_tax = self.get_capitalized_valuation_tax()
|
||||
|
||||
for tax in self.get("taxes"):
|
||||
amount, base_amount = self.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
@@ -1469,8 +1473,7 @@ class PurchaseInvoice(BuyingController):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax:
|
||||
# credit valuation tax amount in "Expenses Included In Valuation"
|
||||
|
||||
@@ -3008,6 +3008,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
party_link.delete()
|
||||
|
||||
def test_purchase_invoice_cancellation_post_account_freezing_date(self):
|
||||
pi = make_purchase_invoice()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
|
||||
try:
|
||||
self.assertRaises(frappe.ValidationError, pi.cancel)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
|
||||
def set_advance_flag(company, flag, default_account):
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
"add_deduct_tax",
|
||||
"charge_type",
|
||||
"row_id",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"col_break1",
|
||||
"account_head",
|
||||
"description",
|
||||
"section_break_mvae",
|
||||
"is_tax_withholding_account",
|
||||
"set_by_item_tax_template",
|
||||
"allocate_full_amount_to_stock_items",
|
||||
"column_break_odzz",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"section_break_10",
|
||||
"rate",
|
||||
"accounting_dimensions_section",
|
||||
@@ -78,6 +81,15 @@
|
||||
"oldfieldname": "row_id",
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
|
||||
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
|
||||
"fieldname": "allocate_full_amount_to_stock_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allocate Full Amount to Stock Items",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
|
||||
@@ -272,13 +284,21 @@
|
||||
"label": "Don't Recompute Tax",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_mvae",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_odzz",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-24 18:22:56.886010",
|
||||
"modified": "2026-06-21 17:08:57.096729",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
|
||||
account_currency: DF.Link | None
|
||||
account_head: DF.Link
|
||||
add_deduct_tax: DF.Literal["Add", "Deduct"]
|
||||
allocate_full_amount_to_stock_items: DF.Check
|
||||
base_net_amount: DF.Currency
|
||||
base_tax_amount: DF.Currency
|
||||
base_tax_amount_after_discount_amount: DF.Currency
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -716,7 +716,7 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
|
||||
return company
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator
|
||||
|
||||
|
||||
@@ -414,39 +414,29 @@ class BuyingController(SubcontractingController):
|
||||
stock_and_asset_items = []
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
|
||||
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
|
||||
last_item_idx = 1
|
||||
for d in self.get("items"):
|
||||
if d.item_code:
|
||||
stock_and_asset_items_qty += flt(d.qty)
|
||||
stock_and_asset_items_amount += flt(d.base_net_amount)
|
||||
(
|
||||
tax_accounts,
|
||||
total_valuation_amount,
|
||||
all_item_charges,
|
||||
stock_item_charges,
|
||||
) = self.get_tax_details()
|
||||
|
||||
last_item_idx = d.idx
|
||||
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row idx).
|
||||
actual_charge_per_item = self.distribute_actual_tax_amount(
|
||||
stock_and_asset_items, all_item_charges, stock_item_charges
|
||||
)
|
||||
|
||||
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
|
||||
remaining_amount = total_actual_tax_amount
|
||||
last_item_idx = max((d.idx for d in self.get("items")), default=1)
|
||||
|
||||
for i, item in enumerate(self.get("items")):
|
||||
if item.item_code and (item.qty or item.get("rejected_qty")):
|
||||
item_tax_amount, actual_tax_amount = 0.0, 0.0
|
||||
if i == (last_item_idx - 1):
|
||||
# dump any rounding remainder of the On Net Total valuation on the last item
|
||||
item_tax_amount = total_valuation_amount
|
||||
actual_tax_amount = remaining_amount
|
||||
else:
|
||||
# calculate item tax amount
|
||||
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
|
||||
total_valuation_amount -= item_tax_amount
|
||||
|
||||
if total_actual_tax_amount:
|
||||
actual_tax_amount = self.get_item_actual_tax_amount(
|
||||
item,
|
||||
total_actual_tax_amount,
|
||||
stock_and_asset_items_amount,
|
||||
stock_and_asset_items_qty,
|
||||
)
|
||||
|
||||
remaining_amount -= actual_tax_amount
|
||||
|
||||
# This code is required here to calculate the correct valuation for stock items
|
||||
if item.item_code not in stock_and_asset_items:
|
||||
item.valuation_rate = 0.0
|
||||
@@ -454,7 +444,8 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
# Item tax amount is the total tax amount applied on that item and actual tax type amount
|
||||
item.item_tax_amount = flt(
|
||||
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
|
||||
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item)
|
||||
@@ -503,7 +494,11 @@ class BuyingController(SubcontractingController):
|
||||
def get_tax_details(self):
|
||||
tax_accounts = []
|
||||
total_valuation_amount = 0.0
|
||||
total_actual_tax_amount = 0.0
|
||||
# Per-row "Actual" valuation charge amounts, kept separate (not pooled) so each can be
|
||||
# distributed individually - this keeps the per-item item_tax_amount in lockstep with the
|
||||
# per-tax-row amount capitalized in the GL (see get_capitalized_valuation_tax).
|
||||
all_item_charges = []
|
||||
stock_item_charges = []
|
||||
|
||||
for d in self.get("taxes"):
|
||||
if d.category not in ["Valuation", "Valuation and Total"]:
|
||||
@@ -516,10 +511,13 @@ class BuyingController(SubcontractingController):
|
||||
if d.charge_type == "On Net Total":
|
||||
total_valuation_amount += amount
|
||||
tax_accounts.append(d.account_head)
|
||||
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
|
||||
# Capitalize the full amount onto stock/asset items only (e.g. Freight)
|
||||
stock_item_charges.append(amount)
|
||||
else:
|
||||
total_actual_tax_amount += amount
|
||||
all_item_charges.append(amount)
|
||||
|
||||
return tax_accounts, total_valuation_amount, total_actual_tax_amount
|
||||
return tax_accounts, total_valuation_amount, all_item_charges, stock_item_charges
|
||||
|
||||
def get_item_tax_amount(self, item, tax_accounts):
|
||||
item_tax_amount = 0.0
|
||||
@@ -540,16 +538,81 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
return item_tax_amount
|
||||
|
||||
def get_item_actual_tax_amount(
|
||||
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
|
||||
):
|
||||
item_proportion = (
|
||||
flt(item.base_net_amount) / stock_and_asset_items_amount
|
||||
if stock_and_asset_items_amount
|
||||
else flt(item.qty) / stock_and_asset_items_qty
|
||||
def distribute_actual_tax_amount(self, stock_and_asset_items, all_item_charges, stock_item_charges):
|
||||
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
|
||||
|
||||
Each charge is spread individually (not pooled together) so the resulting per-item
|
||||
item_tax_amount decomposes exactly into the per-tax-row amount capitalized in the GL
|
||||
(see get_capitalized_valuation_tax) - pooling first and spreading the aggregate can drift
|
||||
by rounding for multiple charges over unevenly valued items. A charge in `all_item_charges`
|
||||
is spread across every item by net amount; a non-stock item's share is computed but never
|
||||
capitalized (e.g. a genuine tax). A charge in `stock_item_charges` (flagged
|
||||
`allocate_full_amount_to_stock_items`) is spread across stock/asset items only, so the whole
|
||||
charge is capitalized (e.g. Freight).
|
||||
"""
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
|
||||
|
||||
charge_per_item = {}
|
||||
for charge in all_item_charges:
|
||||
self._spread_charge_over_items(charge_per_item, charge, all_items)
|
||||
for charge in stock_item_charges:
|
||||
self._spread_charge_over_items(charge_per_item, charge, stock_items)
|
||||
return charge_per_item
|
||||
|
||||
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
|
||||
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
|
||||
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
|
||||
to the last item in the group."""
|
||||
if not total_charge or not items:
|
||||
return
|
||||
|
||||
total_amount = sum(flt(d.base_net_amount) for d in items)
|
||||
total_qty = sum(flt(d.qty) for d in items)
|
||||
|
||||
# Nothing to proportion against (all rows have zero amount and zero qty)
|
||||
if not total_amount and not total_qty:
|
||||
return
|
||||
|
||||
remaining = total_charge
|
||||
for d in items[:-1]:
|
||||
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
|
||||
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
|
||||
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
|
||||
remaining -= charge
|
||||
|
||||
last = items[-1]
|
||||
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
|
||||
remaining, self.precision("item_tax_amount", last)
|
||||
)
|
||||
|
||||
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
|
||||
def get_capitalized_valuation_tax(self):
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
|
||||
|
||||
capitalized = {}
|
||||
for tax in self.get("taxes"):
|
||||
if tax.category not in ("Valuation", "Valuation and Total"):
|
||||
continue
|
||||
|
||||
amount = flt(tax.base_tax_amount_after_discount_amount) * (
|
||||
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
|
||||
)
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
|
||||
# Spread across all items; only the stock/asset items' share is capitalized.
|
||||
charge_per_item = {}
|
||||
self._spread_charge_over_items(charge_per_item, amount, all_items)
|
||||
amount = sum(
|
||||
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
|
||||
)
|
||||
|
||||
capitalized[tax.name] = amount
|
||||
|
||||
return capitalized
|
||||
|
||||
def set_incoming_rate(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
|
||||
|
||||
|
||||
@@ -186,7 +186,8 @@ class StatusUpdater(Document):
|
||||
"""
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
if self.meta.has_field("status"):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def update_prevdoc_status(self):
|
||||
self.update_qty()
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.naming import make_autoname, revert_series_if_last
|
||||
from frappe.query_builder.functions import CurDate, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
from frappe.utils.data import add_days
|
||||
|
||||
@@ -94,6 +93,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
|
||||
@@ -379,50 +379,6 @@ def make_batch_bundle(
|
||||
)
|
||||
|
||||
|
||||
def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None):
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(batch)
|
||||
.join(sle)
|
||||
.on(batch.batch_id == sle.batch_no)
|
||||
.select(
|
||||
batch.batch_id,
|
||||
Sum(sle.actual_qty).as_("qty"),
|
||||
)
|
||||
.where(
|
||||
(sle.item_code == item_code)
|
||||
& (sle.warehouse == warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
& ((batch.expiry_date >= CurDate()) | (batch.expiry_date.isnull()))
|
||||
)
|
||||
.groupby(batch.batch_id)
|
||||
.orderby(batch.expiry_date, batch.creation)
|
||||
)
|
||||
|
||||
if serial_no and frappe.get_cached_value("Item", item_code, "has_batch_no"):
|
||||
serial_nos = get_serial_nos(serial_no)
|
||||
batches = frappe.get_all(
|
||||
"Serial No",
|
||||
fields=["batch_no"],
|
||||
filters={"item_code": item_code, "warehouse": warehouse, "name": ("in", serial_nos)},
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not batches:
|
||||
validate_serial_no_with_batch(serial_nos, item_code)
|
||||
|
||||
if batches and len(batches) > 1:
|
||||
return []
|
||||
|
||||
query = query.where(batch.name == batches[0].batch_no)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def validate_serial_no_with_batch(serial_nos, item_code):
|
||||
if frappe.get_cached_value("Serial No", serial_nos[0], "item_code") != item_code:
|
||||
frappe.throw(
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -886,6 +886,12 @@ class PurchaseReceipt(BuyingController):
|
||||
|
||||
def make_tax_gl_entries(self, gl_entries, via_landed_cost_voucher=False):
|
||||
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get("items")])
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name. This is what must be credited to each tax account - a non-stock item's share
|
||||
# of a spread-across-all-items charge is not capitalized, so it is excluded here.
|
||||
capitalized_valuation_tax = self.get_capitalized_valuation_tax()
|
||||
|
||||
# Cost center-wise amount breakup for other charges included for valuation
|
||||
valuation_tax = {}
|
||||
for tax in self.get("taxes"):
|
||||
@@ -898,10 +904,8 @@ class PurchaseReceipt(BuyingController):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(
|
||||
tax.base_tax_amount_after_discount_amount
|
||||
)
|
||||
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if negative_expense_to_be_booked and valuation_tax:
|
||||
# Backward compatibility:
|
||||
|
||||
@@ -1334,11 +1334,12 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
pr.delete()
|
||||
|
||||
def test_valuation_tax_distribution_with_non_stock_item(self):
|
||||
"""A "Valuation and Total" tax is distributed across all items by net amount, but only
|
||||
stock/asset items can carry valuation. For a document with 2 stock items + 1 service
|
||||
item (each net 100) and a 30 valuation tax, each item's share is 10; only the two stock
|
||||
items capitalize their share (20 total), so the non-stock item's 10 share must not be
|
||||
capitalized onto the stock items."""
|
||||
"""When "Allocate Full Amount to Stock Items" is unchecked, a "Valuation and Total"
|
||||
actual charge is distributed across all items by net amount, but only stock/asset items
|
||||
can carry valuation. For a document with 2 stock items + 1 service item (each net 100)
|
||||
and a 30 valuation charge, each item's share is 10; only the two stock items capitalize
|
||||
their share (20 total), so the non-stock item's 10 share must not be capitalized onto the
|
||||
stock items."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
@@ -1373,6 +1374,8 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Valuation Tax",
|
||||
"tax_amount": 30,
|
||||
# Spread across all items (incl. non-stock); do not allocate full amount to stock items
|
||||
"allocate_full_amount_to_stock_items": 0,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1400,6 +1403,231 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
# Only the stock items' share (20) is capitalized; the service item's 10 is excluded
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2)
|
||||
|
||||
def test_full_actual_charge_capitalized_on_stock_items_only(self):
|
||||
"""When "Allocate Full Amount to Stock Items" is checked (the default), an actual
|
||||
valuation charge such as Freight is fully capitalized onto stock/asset items only. For a
|
||||
document with 2 stock items + 1 service item (each net 100) and a 30 freight charge, the
|
||||
charge is distributed over the 200 stock net only: 15 per stock item, and the entire 30
|
||||
is capitalized (nothing is lost to the non-stock item)."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
# Order matters: stock, service, stock (service item in the middle)
|
||||
for code in (stock_item1, service_item, stock_item2):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Shipping Charges - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Freight",
|
||||
"tax_amount": 30,
|
||||
# Default behavior: allocate the full amount to stock/asset items only
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# 30 freight / 200 stock net = 15 per stock item. The service item carries nothing.
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 15.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].item_tax_amount, 15.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 115.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].valuation_rate, 115.0, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 200 (goods) + 30 (the entire freight charge)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 230.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2)
|
||||
# The whole freight charge (30) is capitalized
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 30.0, places=2)
|
||||
|
||||
def test_actual_charge_distribution_with_both_allocation_modes(self):
|
||||
"""Both allocation modes can coexist on the same document, and each item's share from
|
||||
each charge adds up. For 2 stock items + 1 service item (each net 100):
|
||||
- a 30 charge with the flag unchecked spreads over all 3 items (10 each); the service
|
||||
item's 10 is not capitalized, so each stock item keeps 10.
|
||||
- a 20 charge with the flag checked spreads over the 2 stock items only (10 each).
|
||||
So each stock item carries 10 + 10 = 20, and the service item carries nothing."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
# Order matters: stock, service, stock (service item in the middle)
|
||||
for code in (stock_item1, service_item, stock_item2):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
# Spread across all items (service share dropped)
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Shipping Charges - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Valuation Tax",
|
||||
"tax_amount": 30,
|
||||
"allocate_full_amount_to_stock_items": 0,
|
||||
},
|
||||
)
|
||||
# Allocate the full amount to stock items only
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Customs Duty - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Freight",
|
||||
"tax_amount": 20,
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# Each stock item: 10 (all-items charge) + 10 (stock-only charge) = 20
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 20.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].item_tax_amount, 20.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 120.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].valuation_rate, 120.0, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 200 (goods) + 20 (stock share of the spread charge) + 20 (the full freight)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 240.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2)
|
||||
# Only the stock items' 20 share of the spread charge is capitalized (service 10 excluded)
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2)
|
||||
# The whole freight charge (20) is capitalized
|
||||
self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 20.0, places=2)
|
||||
|
||||
def test_multiple_actual_charges_per_item_matches_gl_per_account(self):
|
||||
"""With multiple "Actual" valuation charges over unevenly valued stock items, each charge
|
||||
is distributed individually so the per-item item_tax_amount decomposes exactly into the
|
||||
per-tax-row amount capitalized in the GL (no rounding drift between the two paths).
|
||||
|
||||
2 stock items with net 100 and 200 (total 300), and two freight charges of 10 each, both
|
||||
flagged to capitalize fully onto stock items. Distributing each charge separately gives
|
||||
item1 = round(100/300*10) * 2 = 3.33 * 2 = 6.66 and item2 = 6.67 * 2 = 13.34. Pooling the
|
||||
two charges into 20 first and spreading the aggregate would instead put 6.67 on item1,
|
||||
which no longer matches the 3.33 + 3.33 implied by the two per-account GL credits."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
for code, rate in ((stock_item1, 100), (stock_item2, 200)):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": rate,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
for account, amount in (
|
||||
("_Test Account Shipping Charges - TCP1", 10),
|
||||
("_Test Account Customs Duty - TCP1", 10),
|
||||
):
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": account,
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": account,
|
||||
"tax_amount": amount,
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# Each charge spread on its own: 3.33 + 3.33 = 6.66 and 6.67 + 6.67 = 13.34 (total 20)
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 6.66, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 13.34, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 106.66, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].valuation_rate, 213.34, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 300 (goods) + 10 + 10 (both freight charges fully capitalized)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 320.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 300.0, places=2)
|
||||
# Each charge is credited in full to its own account
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 10.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 10.0, places=2)
|
||||
|
||||
def test_po_to_pi_and_po_to_pr_worflow_full(self):
|
||||
"""Test following behaviour:
|
||||
- Create PO
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2186,6 +2186,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
] += flt(t.base_amount * multiply_based_on) / divide_based_on
|
||||
|
||||
if item_account_wise_additional_cost:
|
||||
precision = self.get_debit_field_precision()
|
||||
|
||||
for d in self.get("items"):
|
||||
for account, amount in item_account_wise_additional_cost.get(
|
||||
(d.item_code, d.name), {}
|
||||
@@ -2193,6 +2195,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
amount["amount"] = flt(amount["amount"], precision)
|
||||
amount["base_amount"] = flt(amount["base_amount"], precision)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
@@ -4646,13 +4651,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)
|
||||
|
||||
@@ -543,6 +543,60 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
sorted([[stock_in_hand_account, 1200, 0.0], ["Cost of Goods Sold - TCP1", 0.0, 1200.0]]),
|
||||
)
|
||||
|
||||
def test_additional_cost_no_rounding_residual_on_stock_adjustment(self):
|
||||
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
|
||||
warehouse = "Stores - TCP1"
|
||||
items = [
|
||||
make_item(f"_Test Addl Cost Rounding {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C")
|
||||
]
|
||||
|
||||
for item_code in items:
|
||||
make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, basic_rate=10)
|
||||
|
||||
transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True)
|
||||
transfer.from_warehouse = warehouse
|
||||
transfer.to_warehouse = warehouse
|
||||
transfer.items = []
|
||||
for item_code in items:
|
||||
transfer.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"qty": 100,
|
||||
"s_warehouse": warehouse,
|
||||
"t_warehouse": warehouse,
|
||||
"uom": "Nos",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
transfer.append(
|
||||
"additional_costs",
|
||||
{
|
||||
"expense_account": "Expenses Included In Valuation - TCP1",
|
||||
"description": "freight",
|
||||
"amount": 100,
|
||||
},
|
||||
)
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Stock Entry", "voucher_no": transfer.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
gl_map = {}
|
||||
for row in gl_entries:
|
||||
account = gl_map.setdefault(row.account, frappe._dict(debit=0.0, credit=0.0))
|
||||
account.debit += row.debit
|
||||
account.credit += row.credit
|
||||
|
||||
self.assertNotIn("Stock Adjustment - TCP1", gl_map)
|
||||
|
||||
stock_in_hand_account = get_inventory_account(company, warehouse)
|
||||
self.assertEqual(flt(gl_map[stock_in_hand_account].debit, 2), 99.99)
|
||||
self.assertEqual(flt(gl_map["Expenses Included In Valuation - TCP1"].credit, 2), 99.99)
|
||||
|
||||
def check_stock_ledger_entries(self, voucher_type, voucher_no, expected_sle):
|
||||
expected_sle.sort(key=lambda x: x[1])
|
||||
|
||||
@@ -1089,6 +1143,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