Compare commits

...

52 Commits

Author SHA1 Message Date
MochaMind
6f9954bb62 chore: update POT file (#56254) 2026-06-21 14:25:56 +02:00
rohitwaghchaure
3f53af8b1f fix: placement of fields (#56257) 2026-06-21 12:00:20 +00:00
rohitwaghchaure
9469889bd5 feat: allocate full actual charge to stock items only (e.g. Freight) (backport #56102) (#56222)
* feat: allocate full actual charge to stock items only (e.g. Freight)

Backport of #56102 to version-16-hotfix. Adapts the GL valuation-tax change
to the inline make_tax_gl_entries in purchase_receipt.py (no services/ refactor
on hotfix) and additionally applies it to purchase_invoice.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: distribute each Actual valuation charge individually

distribute_actual_tax_amount pooled all "Actual" valuation charges (both
the spread-across-all-items charges and the allocate_full_amount_to_stock_items
freight charges) into single totals before spreading, while the GL path
(get_capitalized_valuation_tax) capitalizes each tax row separately. For
multiple charges over unevenly valued items, pool-then-spread can drift by a
rounding cent from spread-each-then-sum, so a row's item_tax_amount no longer
decomposed exactly into the per-account capitalized GL amounts (the document
total still balanced).

get_tax_details now returns the per-row charge amounts as lists and
distribute_actual_tax_amount spreads each charge on its own, mirroring
get_capitalized_valuation_tax. Per-item valuation now reconciles exactly with
per-account GL credits. Single-charge behaviour is unchanged.

Adds test_multiple_actual_charges_per_item_matches_gl_per_account covering two
freight charges over items of net 100 and 200 (asserts 6.66 / 13.34, which the
old pooled logic would have rounded to 6.67 / 13.33).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:21:06 +05:30
Mihir Kandoi
6e61ee8d70 Merge pull request #56201 from aerele/backport-#56077
fix(stock): apply precision to the additional cost amount in stock entry
2026-06-21 13:44:17 +05:30
Mihir Kandoi
dcf076aad6 Merge pull request #56245 from frappe/mergify/bp/version-16-hotfix/pr-56235
refactor(stock): remove dead get_batches() in batch.py (backport #56235)
2026-06-21 13:08:18 +05:30
Mihir Kandoi
edd18fd650 refactor(stock): remove dead get_batches() in batch.py
batch.get_batches(item_code, warehouse, ...) was added by #55647 and has no callers
anywhere in erpnext, frappe, or payments (not whitelisted, not referenced from JS/hooks).
It is also obsolete: it joins Stock Ledger Entry on `batch_no`, which the Serial and
Batch Bundle system no longer populates, so it returns nothing even on MariaDB. Its
query was additionally Postgres-invalid (GROUP BY batch_id with ORDER BY expiry_date/
creation -> GroupingError, since batch_id is not the primary key).

Remove the dead function (and its now-unused CurDate/Sum import) rather than fix a query
that nothing can reach. Live batch-quantity lookups go through get_batch_qty() /
get_auto_batch_nos(), which use the bundle model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 345cbc97e1)
2026-06-21 07:18:19 +00:00
Mihir Kandoi
62209348a4 Merge pull request #56232 from frappe/mergify/bp/version-16-hotfix/pr-56228
fix: disarding stock entry fix (backport #56228)
2026-06-21 10:41:31 +05:30
nishkagosalia
537225494c fix: disarding stock entry fix
(cherry picked from commit debe1855c6)
2026-06-21 04:47:04 +00:00
Sudharsanan11
20b14395e3 test(stock): add test to validate the precision for additional cost amount 2026-06-20 23:51:16 +05:30
Sudharsanan11
6ac699d3bb fix(stock): apply precision to the additional cost amount in stock entry 2026-06-20 23:50:50 +05:30
mergify[bot]
e6e5591088 fix(coa_importer): allow importing COA through import_coa only for Accounts Manager (backport #56132) (#56140)
* fix(coa_importer): allow importing COA through `import_coa` only for `Accounts Manager` (#56132)

* fix(coa_importer): allow importing COA only for `Accounts Manager`

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>

* fix(coa_importer): check permissions in `unset_existing_data`

---------

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>
(cherry picked from commit 8c1a1aafe6)

# Conflicts:
#	erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py

* chore: resolve conflicts

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-20 22:16:16 +05:30
Diptanil Saha
635c51acf7 Merge pull request #56194 from frappe/mergify/bp/version-16-hotfix/pr-56191
fix: added missing permission validation on whitelisted function and removed unnecessary whitelisted decorator (backport #56191)
2026-06-20 20:25:41 +05:30
diptanilsaha
e605675e11 chore: resolve conflicts 2026-06-20 20:07:42 +05:30
diptanilsaha
bf58393fda fix(report_utils): remove unnecessary whitelist decorator on get_invoiced_item_gross_margin
(cherry picked from commit e29535f29c)

# Conflicts:
#	erpnext/accounts/report/utils.py
2026-06-20 20:07:42 +05:30
diptanilsaha
88ce356d62 fix(err): add missing permission check on get_account_details
(cherry picked from commit 9bf1e847d2)
2026-06-20 20:07:37 +05:30
Shllokkk
396feadace Merge pull request #56164 from Shllokkk/honor-account-freezing-date-on-cancel
fix: honor account freezing date when cancelling vouchers
2026-06-20 13:56:12 +05:30
mergify[bot]
c0dab55fcc perf: composite index on (serial_no, warehouse, posting_datetime) for Serial and Batch Entry (backport #56032) (#56166)
* perf: composite index on (serial_no, warehouse, posting_datetime)

(cherry picked from commit b1b6ae98ed)

# Conflicts:
#	erpnext/patches.txt

* chore: fix conflicts

Removed conflicting patch entries and retained relevant ones.

* chore: fix conflicts

---------

Co-authored-by: Rohit Waghchaure <rohitw1991@gmail.com>
2026-06-19 12:54:19 +00:00
ruthra kumar
cb47745d8c Merge pull request #56159 from frappe/mergify/bp/version-16-hotfix/pr-55265
fix: update reference doctype mapping and field visibility in bank guarantee (backport #55265)
2026-06-19 17:48:27 +05:30
Shllokkk
f4b827cb3d fix: honor account freezing date when cancelling vouchers 2026-06-19 16:51:54 +05:30
nareshkannasln
dc9ae20db8 fix: update reference doctype mapping and field visibility in bank guarantee
(cherry picked from commit b1de654dfd)
2026-06-19 11:09:38 +00:00
Mihir Kandoi
6cb42ab8b1 Merge pull request #56138 from frappe/mergify/bp/version-16-hotfix/pr-55920
fix: update weighted average rate calculation to consider returned and consumed quantities (backport #55920)
2026-06-19 15:15:45 +05:30
ljain112
35e06045bd fix: update weighted average rate calculation to consider returned and consumed quantities
(cherry picked from commit 35e55d3e13)
2026-06-19 09:17:57 +00:00
Mihir Kandoi
6c37acc180 Merge pull request #55968 from frappe/mergify/bp/version-16-hotfix/pr-55830
fix(stock): enable quality inspection for all Stock Entry purposes (backport #55830)
2026-06-19 12:03:46 +05:30
Sudharsanan11
42c121a750 fix(stock): define qi exception classes in exceptions file 2026-06-19 11:38:25 +05:30
Mihir Kandoi
1e027364e3 chore: resolve conflicts 2026-06-19 11:38:25 +05:30
Sudharsanan11
d2fee32eb3 test(stock): add test to validate the quality inspection for stock entry
(cherry picked from commit 609ccc3cb1)
2026-06-19 11:38:25 +05:30
Smit Vora
21912402c0 Merge pull request #56123 from frappe/mergify/bp/version-16-hotfix/pr-56104
fix: base_tax_amount as none when payment entry created using API (backport #56104)
2026-06-19 09:25:27 +05:30
vorasmit
43b355eaf6 fix: tax.base_tax_amount as none when payment entry created using API
(cherry picked from commit b9b402f2ec)
2026-06-19 03:17:58 +00:00
Mihir Kandoi
175aac4156 Merge pull request #56119 from frappe/mergify/bp/version-16-hotfix/pr-56065
fix(stock): propagate renamed attribute values to variant items (backport #56065)
2026-06-18 23:16:26 +05:30
Mihir Kandoi
30650f298b Merge pull request #56115 from frappe/mergify/bp/version-16-hotfix/pr-56055
fix: disable is_debit_note while creating credit note (backport #56055)
2026-06-18 22:54:51 +05:30
barredterra
3110ab1c57 fix(stock): update variant attributes on value rename
(cherry picked from commit c7acd88742)
2026-06-18 17:06:07 +00:00
barredterra
40110d83c9 test(stock): add cleanup for item attribute value changes in tests
(cherry picked from commit 60f5de7ab8)
2026-06-18 17:06:07 +00:00
barredterra
dbc831e008 fix(stock): propagate renamed attribute values to variant items
(cherry picked from commit 27d574dad5)
2026-06-18 17:06:07 +00:00
Mihir Kandoi
686437bd54 Merge pull request #56117 from frappe/mergify/bp/version-16-hotfix/pr-56098
fix: apply docstatus filter to exclude cancelled Work Orders in Seria… (backport #56098)
2026-06-18 22:32:47 +05:30
pandiyan
58d5f39e0a fix: apply docstatus filter to exclude cancelled Work Orders in Serial No
(cherry picked from commit 3ba8f690a4)
2026-06-18 16:56:46 +00:00
pandiyan
c7dbedbfdc fix: disable is_debit_note while creating credit note
(cherry picked from commit 279c8dea06)
2026-06-18 16:54:21 +00:00
Nikhil Kothari
8e21af0a63 fix: type def in get_linked_payments (#56100) 2026-06-18 14:11:11 +00:00
Shllokkk
87e498cd7d Merge pull request #56088 from Shllokkk/je-pcv-vaidation
fix(journal entry): validate opening entry against pcv on save
2026-06-18 18:04:51 +05:30
Shllokkk
f8aa4c730c fix(journal entry): validate opening entry against pcv on save 2026-06-18 16:56:17 +05:30
rohitwaghchaure
a335838691 Merge pull request #56091 from frappe/mergify/bp/version-16-hotfix/pr-56079
feat: allow negative stock at batch level (backport #56079)
2026-06-18 16:42:32 +05:30
Rohit Waghchaure
1f075d4bbf feat: add batch-level option to allow negative stock for batch
(cherry picked from commit ca07982ee0)
2026-06-18 10:49:45 +00:00
Khushi Rawat
7f441864d6 Merge pull request #56084 from frappe/mergify/bp/version-16-hotfix/pr-56030
fix: lock budget distribution table and guard against null distributi… (backport #56030)
2026-06-18 14:34:18 +05:30
Shllokkk
2b28b7e694 fix: lock budget distribution table and guard against null distribution rows
(cherry picked from commit d37e5cd97d)
2026-06-18 08:28:37 +00:00
Mihir Kandoi
56d9cbabbf Merge pull request #56049 from aerele/backport-56003
fix(stock): update transfer status for mixed transfer flows
2026-06-17 17:20:55 +05:30
pandiyan
4481efec17 test(stock): validate completed status for mixed transfer methods 2026-06-17 16:58:32 +05:30
pandiyan
84a1a51023 fix(stock): update transfer status for mixed transfer flows 2026-06-17 16:58:23 +05:30
Mihir Kandoi
98f45221e6 Merge pull request #56043 from mihir-kandoi/codex/fix-stock-ageing-reco-ageing-v16
fix: preserve stock ageing on non-serial reconciliation
2026-06-17 16:20:49 +05:30
Mihir Kandoi
846e0a9f06 fix: preserve stock ageing on non-serial reconciliation 2026-06-17 15:58:47 +05:30
ruthra kumar
6185507614 Merge pull request #56024 from frappe/mergify/bp/version-16-hotfix/pr-55988
refactor(test): remove dependency on accounts test mixin (backport #55988)
2026-06-17 07:59:00 +05:30
ruthra kumar
d051407126 refactor(test): remove redundant clear method and minor fixes
(cherry picked from commit 004087097c)
2026-06-17 02:11:27 +00:00
ruthra kumar
3d91e021a3 refactor(tests): replace AccountsTestMixin master data setup with direct attribute assignments
All test classes inheriting AccountsTestMixin that called create_company(),
create_item(), create_customer(), create_supplier(), create_usd_receivable_account(),
and create_usd_payable_account() in setUp() now set instance attributes directly
using master data pre-created by BootStrapTestData, eliminating redundant DB
inserts on every test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 1fda0dfb9b)
2026-06-17 02:11:26 +00:00
Sudharsanan11
a6310351fd fix(stock): enable quality inspection for all Stock Entry purposes
- Remove `depends_on` restriction from `inspection_required` field so it
  is visible for all Stock Entry purposes, not just Manufacture
- Fix `check_item_quality_inspection` to return items for Stock Entry
  (was returning [] for unknown doctypes, blocking QI creation flow)
- Fix `inspection_type` in transaction.js to be purpose-aware: Manufacture
  and Material Receipt → "Incoming"; all other purposes → "Outgoing"

(cherry picked from commit dceb9a3c6c)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.json
2026-06-16 09:38:17 +00:00
71 changed files with 4378 additions and 1049 deletions

View File

@@ -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):
"""

View File

@@ -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: {

View File

@@ -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": [
{

View File

@@ -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,

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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);
});

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View File

@@ -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):

View File

@@ -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"))

View File

@@ -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):

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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")

View File

@@ -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"

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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"}]
)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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):
"""

View File

@@ -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:

View File

@@ -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(

View File

@@ -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):

View File

@@ -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):

View File

@@ -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 = [

View File

@@ -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"

View File

@@ -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(

View File

@@ -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(

View File

@@ -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")

View File

@@ -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):

View File

@@ -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")

View File

@@ -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):

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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"

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"):

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"])

View File

@@ -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");
},

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)
)

View File

@@ -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).

View File

@@ -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()