mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-27 12:58:34 +00:00
Compare commits
27 Commits
fix-asset-
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081fbe9e3f | ||
|
|
057af21cd8 | ||
|
|
c7ef42ef98 | ||
|
|
79ad11e21b | ||
|
|
485e9041de | ||
|
|
5e60e4faa7 | ||
|
|
3f053e599c | ||
|
|
b0232f41ea | ||
|
|
1d517375d9 | ||
|
|
1d9d982719 | ||
|
|
66a711c849 | ||
|
|
a2f201e1d7 | ||
|
|
b989bef967 | ||
|
|
c1b91b0f5f | ||
|
|
31f89b72b4 | ||
|
|
0458446a06 | ||
|
|
a001a15312 | ||
|
|
7f01d6b24e | ||
|
|
f474c10f89 | ||
|
|
a797c31b57 | ||
|
|
1a820abe3c | ||
|
|
b11a2c3e9f | ||
|
|
2aff857561 | ||
|
|
f2d64d1a2a | ||
|
|
de45ab7fc9 | ||
|
|
5ad8ea3f17 | ||
|
|
c69077cd3a |
@@ -48,7 +48,6 @@ repos:
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/public/js/controllers/.*|
|
||||
erpnext/templates/pages/order.js|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -36,8 +37,20 @@ class AccountingPeriod(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_overlap()
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.start_date) > getdate(self.end_date):
|
||||
frappe.throw(_("Start Date cannot be after End Date"))
|
||||
|
||||
if getdate(self.end_date) > getdate(nowdate()):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Accounting Period cannot be created for a future date. End Date {0} is after today."
|
||||
).format(frappe.bold(frappe.format(self.end_date, "Date")))
|
||||
)
|
||||
|
||||
def before_insert(self):
|
||||
self.bootstrap_doctypes_for_closing()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, nowdate
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import (
|
||||
ClosedAccountingPeriod,
|
||||
@@ -93,7 +93,7 @@ def create_accounting_period(**args):
|
||||
|
||||
accounting_period = frappe.new_doc("Accounting Period")
|
||||
accounting_period.start_date = args.start_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
|
||||
accounting_period.end_date = args.end_date or nowdate()
|
||||
accounting_period.company = args.company or "_Test Company"
|
||||
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
|
||||
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})
|
||||
|
||||
@@ -135,38 +135,9 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
payment_term1 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
|
||||
).insert()
|
||||
payment_term2 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
|
||||
).insert()
|
||||
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test 50-50",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term1.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 15,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term2.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
template = frappe.get_doc("Payment Terms Template", "_Test Payment Term Template")
|
||||
first_term = frappe.get_doc("Payment Term", template.terms[0].payment_term)
|
||||
expected_payment_term = first_term.description or first_term.name
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
@@ -193,12 +164,10 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
).insert()
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
@@ -227,9 +196,7 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
|
||||
).insert()
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
|
||||
@@ -1422,10 +1422,10 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
|
||||
# must be applied explicitly. The report should only show permitted customers.
|
||||
original_customer = self.customer
|
||||
second_customer = "_Test AR Perm Customer"
|
||||
second_customer = "_Test Customer 1"
|
||||
|
||||
# create_customer overrides self.customer, so build the restricted invoice first
|
||||
self.create_customer(customer_name=second_customer)
|
||||
self.customer = second_customer
|
||||
self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
self.customer = original_customer
|
||||
|
||||
@@ -59,16 +59,9 @@ class TestCashFlow(ERPNextTestSuite):
|
||||
|
||||
def test_cash_purchase_of_asset_is_investing_outflow(self):
|
||||
"""Buying a fixed asset for cash is an investing outflow that reduces net change in cash."""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
create_account(
|
||||
account_name="_Test Cash Flow Asset",
|
||||
company=self.company,
|
||||
parent_account="Fixed Assets - _TC",
|
||||
account_type="Fixed Asset",
|
||||
)
|
||||
asset_account = "_Test Cash Flow Asset - _TC"
|
||||
asset_account = "Office Equipment - _TC"
|
||||
|
||||
before = self.net_change_in_cash()
|
||||
# debit the fixed asset, credit cash -> cash goes out
|
||||
|
||||
@@ -64,16 +64,12 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
||||
def test_opening_total_and_closing_balances(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
account = create_account(
|
||||
account_name="_Test GL Account", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
|
||||
account = "_Test Account Cost for Goods Sold - _TC"
|
||||
offset = "_Test Bank - _TC"
|
||||
make_journal_entry(account, offset, 1000, posting_date=add_days(today(), -60), submit=True) # opening
|
||||
make_journal_entry(account, offset, 200, posting_date=today(), submit=True) # in period
|
||||
|
||||
@@ -87,19 +83,13 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
self.assertEqual(labelled["'Closing (Opening + Total)'"]["debit"], 1200)
|
||||
|
||||
def test_categorize_by_account_subtotals(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
account_a = create_account(
|
||||
account_name="_Test GL Account A", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
account_b = create_account(
|
||||
account_name="_Test GL Account B", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
|
||||
account_a = "_Test Account Cost for Goods Sold - _TC"
|
||||
account_b = "_Test Bank - _TC"
|
||||
offset = "_Test Cash - _TC"
|
||||
make_journal_entry(account_a, offset, 300, posting_date=today(), submit=True)
|
||||
make_journal_entry(account_b, offset, 400, posting_date=today(), submit=True)
|
||||
|
||||
|
||||
@@ -547,6 +547,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
@@ -561,7 +562,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-22 12:23:09.241125",
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
|
||||
@@ -23,15 +23,12 @@ erpnext.accounts.taxes = {
|
||||
onload: function (frm) {
|
||||
if (frm.get_field("taxes")) {
|
||||
frm.set_query("account_head", "taxes", function (doc) {
|
||||
let account_type = ["Tax", "Chargeable"];
|
||||
|
||||
if (frm.cscript.tax_table == "Sales Taxes and Charges") {
|
||||
var account_type = ["Tax", "Chargeable", "Expense Account"];
|
||||
account_type.push("Expense Account");
|
||||
} else {
|
||||
var account_type = [
|
||||
"Tax",
|
||||
"Chargeable",
|
||||
"Income Account",
|
||||
"Expenses Included In Valuation",
|
||||
];
|
||||
account_type.push("Income Account", "Expenses Included In Valuation");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -952,14 +952,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
if (["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
|
||||
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
|
||||
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
|
||||
let total_amount_to_pay;
|
||||
|
||||
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
|
||||
var total_amount_to_pay = flt(
|
||||
total_amount_to_pay = flt(
|
||||
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
|
||||
precision("grand_total")
|
||||
);
|
||||
} else {
|
||||
var total_amount_to_pay = flt(
|
||||
total_amount_to_pay = flt(
|
||||
flt(base_grand_total, precision("base_grand_total")) -
|
||||
this.frm.doc.total_advance -
|
||||
this.frm.doc.base_write_off_amount,
|
||||
@@ -1004,14 +1005,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
async set_total_amount_to_default_mop() {
|
||||
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
|
||||
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
|
||||
let total_amount_to_pay;
|
||||
|
||||
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
|
||||
var total_amount_to_pay = flt(
|
||||
total_amount_to_pay = flt(
|
||||
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
|
||||
precision("grand_total")
|
||||
);
|
||||
} else {
|
||||
var total_amount_to_pay = flt(
|
||||
total_amount_to_pay = flt(
|
||||
flt(base_grand_total, precision("base_grand_total")) -
|
||||
this.frm.doc.total_advance -
|
||||
this.frm.doc.base_write_off_amount,
|
||||
|
||||
@@ -1291,13 +1291,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
|
||||
var set_party_account = function (set_pricing) {
|
||||
if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) {
|
||||
if (me.frm.doc.doctype == "Sales Invoice") {
|
||||
var party_type = "Customer";
|
||||
var party_account_field = "debit_to";
|
||||
} else {
|
||||
var party_type = "Supplier";
|
||||
var party_account_field = "credit_to";
|
||||
}
|
||||
let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier";
|
||||
let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to";
|
||||
|
||||
var party = me.frm.doc[frappe.model.scrub(party_type)];
|
||||
if (
|
||||
@@ -2071,7 +2066,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.doc.operations && this.frm.doc.operations.length > 0) {
|
||||
var item_grid = this.frm.fields_dict["operations"].grid;
|
||||
let item_grid = this.frm.fields_dict["operations"].grid;
|
||||
$.each(["base_operating_cost", "base_hour_rate"], function (i, fname) {
|
||||
if (frappe.meta.get_docfield(item_grid.doctype, fname))
|
||||
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
|
||||
@@ -2079,7 +2074,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
|
||||
var item_grid = this.frm.fields_dict["secondary_items"].grid;
|
||||
let item_grid = this.frm.fields_dict["secondary_items"].grid;
|
||||
$.each(["base_rate", "base_amount"], function (i, fname) {
|
||||
if (frappe.meta.get_docfield(item_grid.doctype, fname))
|
||||
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
|
||||
@@ -2470,7 +2465,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
row_to_modify[key] = pr_row[key];
|
||||
}
|
||||
|
||||
if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) {
|
||||
let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center");
|
||||
if (r.message.cost_center) {
|
||||
row_to_modify["cost_center"] = r.message.cost_center;
|
||||
@@ -2735,7 +2730,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
$.each(me.frm.doc.items || [], function (i, item) {
|
||||
if (
|
||||
item.name &&
|
||||
r.message.hasOwnProperty(item.name) &&
|
||||
Object.prototype.hasOwnProperty.call(r.message, item.name) &&
|
||||
r.message[item.name].item_tax_template
|
||||
) {
|
||||
item.item_tax_template = r.message[item.name].item_tax_template;
|
||||
|
||||
@@ -681,6 +681,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
@@ -695,7 +696,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-22 12:23:19.196991",
|
||||
"modified": "2026-06-27 16:12:10.457900",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Customer",
|
||||
|
||||
@@ -94,7 +94,7 @@ class RepostItemValuation(Document):
|
||||
self.validate_recreate_stock_ledgers()
|
||||
|
||||
def set_default_posting_time(self):
|
||||
if not self.posting_time:
|
||||
if self.posting_time is None:
|
||||
self.posting_time = nowtime()
|
||||
|
||||
if not self.posting_date:
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe import _, bold, json, msgprint
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_to_date, cint, cstr, flt, now
|
||||
from frappe.utils.data import DateTimeLikeObject
|
||||
|
||||
@@ -1014,6 +1015,102 @@ class StockReconciliation(StockController):
|
||||
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
|
||||
d.amount_difference = flt(d.amount) - flt(d.current_amount)
|
||||
|
||||
def recalculate_difference_amount_from_ledger(self):
|
||||
"""Sync the displayed current qty/rate and difference amount with the (reposted) ledger.
|
||||
|
||||
Submitted reconciliations freeze ``difference_amount`` and the per-row current values at
|
||||
submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger
|
||||
Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures
|
||||
that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed
|
||||
``stock_value_difference`` keeps the document and the GL consistent by construction.
|
||||
"""
|
||||
difference_amount = 0.0
|
||||
|
||||
for row in self.items:
|
||||
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
|
||||
|
||||
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
|
||||
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
|
||||
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
|
||||
|
||||
current_qty = self.get_current_qty_from_ledger(row)
|
||||
current_valuation_rate = (
|
||||
flt(current_amount / current_qty, row.precision("current_valuation_rate"))
|
||||
if current_qty
|
||||
else 0.0
|
||||
)
|
||||
|
||||
row.db_set(
|
||||
{
|
||||
"amount": amount,
|
||||
"current_qty": current_qty,
|
||||
"current_valuation_rate": current_valuation_rate,
|
||||
"current_amount": current_amount,
|
||||
"quantity_difference": flt(row.qty) - current_qty,
|
||||
"amount_difference": amount_difference,
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
difference_amount += amount_difference
|
||||
|
||||
self.db_set(
|
||||
"difference_amount",
|
||||
flt(difference_amount, self.precision("difference_amount")),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
|
||||
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
|
||||
|
||||
Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation
|
||||
(blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight
|
||||
from the current bundle. Non-serial rows can float, so read the ledger balance just before the
|
||||
reconciliation, excluding the reconciliation's own entries.
|
||||
"""
|
||||
if row.current_serial_and_batch_bundle:
|
||||
total_qty = frappe.db.get_value(
|
||||
"Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty"
|
||||
)
|
||||
return abs(flt(total_qty, row.precision("current_qty")))
|
||||
|
||||
reco_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["posting_datetime", "creation"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not reco_sle:
|
||||
return flt(row.current_qty, row.precision("current_qty"))
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
previous_sle = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.qty_after_transaction)
|
||||
.where(
|
||||
(sle.item_code == row.item_code)
|
||||
& (sle.warehouse == row.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (
|
||||
(sle.posting_datetime < reco_sle.posting_datetime)
|
||||
| (
|
||||
(sle.posting_datetime == reco_sle.posting_datetime)
|
||||
& (sle.creation < reco_sle.creation)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(sle.posting_datetime, order=frappe.qb.desc)
|
||||
.orderby(sle.creation, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
|
||||
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
|
||||
|
||||
def submit(self):
|
||||
if len(self.items) > 100:
|
||||
msgprint(
|
||||
@@ -1223,6 +1320,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
|
||||
return itemwise_batch_data
|
||||
|
||||
|
||||
def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str):
|
||||
"""Net stock value change posted to the GL by a reconciliation row (sum of its SLEs)."""
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == voucher_type)
|
||||
& (sle.voucher_no == voucher_no)
|
||||
& (sle.voucher_detail_no == voucher_detail_no)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_balance_for(
|
||||
item_code: str,
|
||||
|
||||
@@ -786,6 +786,172 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
sr1.load_from_db()
|
||||
self.assertEqual(sr1.difference_amount, 10000)
|
||||
|
||||
def assert_reco_difference_matches_gl(self, reco_name):
|
||||
"""The displayed Difference Amount (doc and per-row) must equal the reposted GL impact,
|
||||
i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``."""
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
||||
get_row_stock_value_difference,
|
||||
)
|
||||
|
||||
reco = frappe.get_doc("Stock Reconciliation", reco_name)
|
||||
total_difference = 0.0
|
||||
|
||||
for row in reco.items:
|
||||
row_difference = flt(
|
||||
get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name),
|
||||
row.precision("amount_difference"),
|
||||
)
|
||||
|
||||
self.assertEqual(flt(row.amount_difference), row_difference)
|
||||
total_difference += row_difference
|
||||
|
||||
self.assertEqual(
|
||||
flt(reco.difference_amount, reco.precision("difference_amount")),
|
||||
flt(total_difference, reco.precision("difference_amount")),
|
||||
)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_non_serialized(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code = self.make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Opening stock => 100 * 100 = 10000
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
# Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=200,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
self.assertEqual(reco.difference_amount, 10000)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=50,
|
||||
rate=50,
|
||||
posting_date=add_days(nowdate(), -3),
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
# Current is now 2500 => difference 20000 - 2500 = 17500
|
||||
self.assertEqual(reco.difference_amount, 17500)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_batched(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
make_landed_cost_voucher,
|
||||
)
|
||||
|
||||
item_code = self.make_item(
|
||||
"Test Batch Item Reco Difference Sync",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TEST-BATCH-DIFFSYNC-.###",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Receive 10 @ 100 (batch value 1000)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
)
|
||||
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
|
||||
|
||||
# Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=500,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
difference_on_submit = reco.difference_amount
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco
|
||||
make_landed_cost_voucher(
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=1000,
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
self.assertNotEqual(reco.difference_amount, difference_on_submit)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_serialized(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
make_landed_cost_voucher,
|
||||
)
|
||||
|
||||
item_code = self.make_item(
|
||||
"Test Serial Item Reco Difference Sync",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "TSIRDS.####",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Receive 5 serial nos @ 100 (value 500)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
)
|
||||
serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
|
||||
|
||||
# Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=500,
|
||||
serial_no="\n".join(serial_nos),
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
difference_on_submit = reco.difference_amount
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco
|
||||
make_landed_cost_voucher(
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=1000,
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
self.assertNotEqual(reco.difference_amount, difference_on_submit)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_make_stock_zero_for_serial_batch_item(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
|
||||
51
erpnext/stock/report/bom_search/test_bom_search.py
Normal file
51
erpnext/stock/report/bom_search/test_bom_search.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.bom_search.bom_search import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBomSearch(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"search_sub_assemblies": 0})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_bom_found_by_contained_item(self):
|
||||
raw_material = "_Test Item"
|
||||
finished_good = "_Test FG Item"
|
||||
|
||||
bom = frappe.get_doc(doctype="BOM", item=finished_good, company="_Test Company", currency="INR")
|
||||
bom.append("items", {"item_code": raw_material, "qty": 1})
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
|
||||
rows = self.run_report(item1=raw_material)
|
||||
bom_names = [row[0] for row in rows]
|
||||
self.assertIn(bom.name, bom_names)
|
||||
|
||||
def test_search_sub_assemblies_finds_top_level_bom(self):
|
||||
raw_material = "_Test Item"
|
||||
sub_assembly = "_Test FG Item" # its default BOM contains _Test Item
|
||||
finished_good = "_Test FG Item 2"
|
||||
|
||||
# top-level BOM uses the sub-assembly (it does NOT list the raw material directly).
|
||||
# the bootstrap sub-assembly BOM is in USD, so match its currency.
|
||||
top_bom = frappe.get_doc(
|
||||
doctype="BOM", item=finished_good, company="_Test Company", currency="USD", conversion_rate=1
|
||||
)
|
||||
top_bom.append("items", {"item_code": sub_assembly, "qty": 1})
|
||||
top_bom.insert()
|
||||
top_bom.submit()
|
||||
|
||||
# search_sub_assemblies=1 scans the exploded tree, so the raw material buried in the
|
||||
# sub-assembly surfaces the top-level BOM
|
||||
deep = [row[0] for row in self.run_report(search_sub_assemblies=1, item1=raw_material)]
|
||||
self.assertIn(top_bom.name, deep)
|
||||
|
||||
# search_sub_assemblies=0 scans only direct BOM Items, so the top-level BOM (which lists
|
||||
# the sub-assembly, not the raw material) is not returned for the raw material
|
||||
direct = [row[0] for row in self.run_report(search_sub_assemblies=0, item1=raw_material)]
|
||||
self.assertNotIn(top_bom.name, direct)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.report.incorrect_stock_value_report.incorrect_stock_value_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company with perpetual inventory"
|
||||
|
||||
|
||||
class TestIncorrectStockValueReport(ERPNextTestSuite):
|
||||
"""Correctness tests for the Incorrect Stock Value report.
|
||||
|
||||
The report is a corruption detector: it walks stock account postings and flags
|
||||
dates/vouchers where the stock ledger value diverges from the GL balance. Clean,
|
||||
balanced perpetual transactions keep ledger value == GL balance, so they must
|
||||
never surface as discrepancy rows.
|
||||
"""
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
company=COMPANY,
|
||||
from_date="2026-01-01",
|
||||
to_date="2026-12-31",
|
||||
)
|
||||
filters.update(extra)
|
||||
return list(execute(filters)[1])
|
||||
|
||||
def test_balanced_account_has_no_discrepancy(self):
|
||||
warehouse = create_warehouse("_Test ISV WH", company=COMPANY)
|
||||
account = frappe.get_value("Warehouse", warehouse, "account")
|
||||
item = "_Test Item"
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
company=COMPANY,
|
||||
posting_date="2026-02-01",
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
from_warehouse=warehouse,
|
||||
qty=4,
|
||||
company=COMPANY,
|
||||
posting_date="2026-03-01",
|
||||
)
|
||||
|
||||
rows = self.run_report(account=account)
|
||||
|
||||
offending = [row for row in rows if row.get("warehouse") == warehouse or row.get("item_code") == item]
|
||||
self.assertEqual(offending, [], f"Balanced perpetual account flagged as incorrect: {offending}")
|
||||
43
erpnext/stock/report/item_where_used/test_item_where_used.py
Normal file
43
erpnext/stock/report/item_where_used/test_item_where_used.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.item_where_used.item_where_used import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestItemWhereUsed(ERPNextTestSuite):
|
||||
"""Correctness tests for the Item Where Used report."""
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(company="_Test Company", **extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_item_used_in_bom_listed(self):
|
||||
raw_material = "_Test Item"
|
||||
finished_good = "_Test FG Item"
|
||||
|
||||
bom = frappe.get_doc(
|
||||
{
|
||||
"doctype": "BOM",
|
||||
"item": finished_good,
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
"quantity": 1,
|
||||
"items": [{"item_code": raw_material, "qty": 1}],
|
||||
}
|
||||
)
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
|
||||
rows = self.run_report(item=raw_material)
|
||||
matching = [row for row in rows if row.document_name == bom.name]
|
||||
|
||||
self.assertTrue(matching, f"BOM {bom.name} not found in report rows for {raw_material}")
|
||||
row = matching[0]
|
||||
self.assertEqual(row.section, "Where Used")
|
||||
self.assertEqual(row.reference_type, "BOM Component")
|
||||
self.assertEqual(row.document_type, "BOM")
|
||||
self.assertEqual(row.related_item, finished_good)
|
||||
self.assertEqual(row.quantity, 1)
|
||||
@@ -18,12 +18,17 @@ def stock_balance(filters):
|
||||
class TestStockBalance(ERPNextTestSuite):
|
||||
# ----------- utils
|
||||
|
||||
# `_Test Item` is a committed bootstrap item that starts at zero stock in `Stores - _TC`,
|
||||
# so transacting here keeps exact qty/value assertions deterministic.
|
||||
test_warehouse = "Stores - _TC"
|
||||
|
||||
def setUp(self):
|
||||
self.item = make_item()
|
||||
self.item = frappe.get_doc("Item", "_Test Item")
|
||||
self.filters = _dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"item_code": [self.item.name],
|
||||
"warehouse": self.test_warehouse,
|
||||
"from_date": "2020-01-01",
|
||||
"to_date": str(today()),
|
||||
}
|
||||
@@ -36,7 +41,7 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
def generate_stock_ledger(self, item_code: str, movements):
|
||||
for movement in map(_dict, movements):
|
||||
if "to_warehouse" not in movement:
|
||||
movement.to_warehouse = "_Test Warehouse - _TC"
|
||||
movement.to_warehouse = self.test_warehouse
|
||||
make_stock_entry(item_code=item_code, **movement)
|
||||
|
||||
def assertInvariants(self, rows):
|
||||
@@ -100,7 +105,7 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
self.item.name,
|
||||
[
|
||||
_dict(qty=5, rate=10),
|
||||
_dict(qty=5, from_warehouse="_Test Warehouse - _TC", to_warehouse=None),
|
||||
_dict(qty=5, from_warehouse=self.test_warehouse, to_warehouse=None),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -153,8 +158,11 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
self.assertInvariants(rows)
|
||||
|
||||
def test_item_group(self):
|
||||
self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
|
||||
|
||||
self.filters.pop("item_code", None)
|
||||
rows = stock_balance(self.filters.update({"item_group": self.item.item_group}))
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all(r.item_group == self.item.item_group for r in rows))
|
||||
|
||||
def test_child_warehouse_balances(self):
|
||||
@@ -172,12 +180,8 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
def test_show_item_attr(self):
|
||||
from erpnext.controllers.item_variant import create_variant
|
||||
|
||||
self.item.has_variants = True
|
||||
self.item.append("attributes", {"attribute": "Test Size"})
|
||||
self.item.save()
|
||||
|
||||
attributes = {"Test Size": "Large"}
|
||||
variant = create_variant(self.item.name, attributes)
|
||||
variant = create_variant("_Test Variant Item", attributes)
|
||||
variant.save()
|
||||
|
||||
self.generate_stock_ledger(variant.name, [_dict(qty=5, rate=10)])
|
||||
@@ -185,12 +189,18 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
self.assertPartialDictEq(attributes, rows[0])
|
||||
self.assertInvariants(rows)
|
||||
|
||||
def make_alt_uom_item(self, uoms=None):
|
||||
"""Fresh item with a controlled UOM table; `_Test Item` already carries an alternate
|
||||
UOM, which would shadow the "first alternate" assertions in these tests."""
|
||||
item = make_item(uoms=uoms)
|
||||
self.filters.update({"item_code": [item.name]})
|
||||
return item
|
||||
|
||||
def test_alt_uom_balance_single_uom(self):
|
||||
"""Alt UOM columns show correct name and converted qty for an item with one alternate UOM."""
|
||||
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
|
||||
self.item.save()
|
||||
item = self.make_alt_uom_item(uoms=[{"conversion_factor": 12, "uom": "Box"}])
|
||||
|
||||
self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)])
|
||||
self.generate_stock_ledger(item.name, [_dict(qty=24, rate=10)])
|
||||
|
||||
rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1}))
|
||||
self.assertEqual(len(rows), 1)
|
||||
@@ -199,7 +209,8 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
|
||||
def test_alt_uom_balance_no_alternate_uom(self):
|
||||
"""Alt UOM columns are not added when no items in the report have alt UOMs."""
|
||||
self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
|
||||
item = self.make_alt_uom_item()
|
||||
self.generate_stock_ledger(item.name, [_dict(qty=5, rate=10)])
|
||||
|
||||
columns, _ = execute(self.filters.update({"show_alt_uom_balance": 1}))
|
||||
col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)]
|
||||
@@ -208,10 +219,9 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
|
||||
def test_alt_uom_balance_filter_disabled(self):
|
||||
"""No alt UOM columns are injected when show_alt_uom_balance is not set."""
|
||||
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
|
||||
self.item.save()
|
||||
item = self.make_alt_uom_item(uoms=[{"conversion_factor": 12, "uom": "Box"}])
|
||||
|
||||
self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)])
|
||||
self.generate_stock_ledger(item.name, [_dict(qty=24, rate=10)])
|
||||
|
||||
columns, _ = execute(self.filters)
|
||||
col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)]
|
||||
@@ -221,11 +231,14 @@ class TestStockBalance(ERPNextTestSuite):
|
||||
def test_alt_uom_balance_uses_first_alternate_uom(self):
|
||||
"""When an item has multiple alt UOMs, only the first (lowest idx) is shown."""
|
||||
frappe.get_doc({"doctype": "UOM", "uom_name": "Carton"}).insert(ignore_if_duplicate=True)
|
||||
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
|
||||
self.item.append("uoms", {"conversion_factor": 144, "uom": "Carton"})
|
||||
self.item.save()
|
||||
item = self.make_alt_uom_item(
|
||||
uoms=[
|
||||
{"conversion_factor": 12, "uom": "Box"},
|
||||
{"conversion_factor": 144, "uom": "Carton"},
|
||||
]
|
||||
)
|
||||
|
||||
self.generate_stock_ledger(self.item.name, [_dict(qty=144, rate=10)])
|
||||
self.generate_stock_ledger(item.name, [_dict(qty=144, rate=10)])
|
||||
|
||||
rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1}))
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
|
||||
|
||||
class TestStockLedgerReport(ERPNextTestSuite):
|
||||
@@ -17,7 +16,8 @@ class TestStockLedgerReport(ERPNextTestSuite):
|
||||
|
||||
A shared `make_movements`/`run` pair keeps each test small without persisting
|
||||
any data: movements are created per test and rolled back, while the report runs
|
||||
read-only.
|
||||
read-only. Tests reuse bootstrap items and transact in `Stores - _TC`, which
|
||||
starts clean (zero balance) for these items.
|
||||
"""
|
||||
|
||||
def make_movements(self, item_code, movements):
|
||||
@@ -35,7 +35,7 @@ class TestStockLedgerReport(ERPNextTestSuite):
|
||||
return list(execute(filters)[1])
|
||||
|
||||
def test_in_out_quantities_and_running_balance(self):
|
||||
item = make_item().name
|
||||
item = "_Test Item"
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
@@ -54,7 +54,7 @@ class TestStockLedgerReport(ERPNextTestSuite):
|
||||
self.assertEqual(issue["qty_after_transaction"], 6)
|
||||
|
||||
def test_opening_balance_reflects_movements_before_from_date(self):
|
||||
item = make_item().name
|
||||
item = "_Test Item"
|
||||
self.make_movements(
|
||||
item,
|
||||
[
|
||||
@@ -79,8 +79,8 @@ class TestStockLedgerReport(ERPNextTestSuite):
|
||||
self.assertEqual(issue["qty_after_transaction"], 6)
|
||||
|
||||
def test_filters_to_requested_item_only(self):
|
||||
item_a = make_item().name
|
||||
item_b = make_item().name
|
||||
item_a = "_Test Item"
|
||||
item_b = "_Test Item 2"
|
||||
self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
|
||||
self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
|
||||
|
||||
|
||||
@@ -4,29 +4,31 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.stock_projected_qty.stock_projected_qty import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
# Use a clean warehouse (zero baseline) so projected-qty assertions are exact.
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
|
||||
|
||||
class TestStockProjectedQty(ERPNextTestSuite):
|
||||
"""Correctness tests for the Stock Projected Qty report (a current-Bin snapshot)."""
|
||||
|
||||
def run_report(self, item_code):
|
||||
def run_report(self, item_code, warehouse=None):
|
||||
filters = frappe._dict(company="_Test Company", item_code=item_code)
|
||||
if warehouse:
|
||||
filters.warehouse = warehouse
|
||||
columns, data = execute(filters)
|
||||
fields = [column["fieldname"] for column in columns]
|
||||
return [dict(zip(fields, row, strict=False)) for row in data]
|
||||
|
||||
def test_projected_qty_includes_actual_and_ordered(self):
|
||||
item = make_item().name
|
||||
item = "_Test Item"
|
||||
make_stock_entry(item_code=item, qty=10, to_warehouse=WAREHOUSE, basic_rate=100)
|
||||
create_purchase_order(item_code=item, qty=5, rate=100, warehouse=WAREHOUSE)
|
||||
|
||||
row = self.run_report(item)[0]
|
||||
row = self.run_report(item, warehouse=WAREHOUSE)[0]
|
||||
self.assertEqual(row["actual_qty"], 10)
|
||||
self.assertEqual(row["ordered_qty"], 5)
|
||||
self.assertEqual(row["projected_qty"], 15)
|
||||
@@ -35,7 +37,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
|
||||
"""projected_qty = actual + ordered + requested + planned
|
||||
- reserved - reserved_for_production - reserved_for_subcontract - reserved_for_production_plan
|
||||
and every component is surfaced as its own column."""
|
||||
item = make_item().name
|
||||
item = "_Test Item"
|
||||
make_stock_entry(item_code=item, qty=100, to_warehouse=WAREHOUSE, basic_rate=100)
|
||||
|
||||
bin_doc = frappe.get_doc("Bin", {"item_code": item, "warehouse": WAREHOUSE})
|
||||
@@ -57,7 +59,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
|
||||
# 100 + 50 + 30 + 20 - 10 - 8 - 6 - 4
|
||||
self.assertEqual(bin_doc.projected_qty, 172)
|
||||
|
||||
row = self.run_report(item)[0]
|
||||
row = self.run_report(item, warehouse=WAREHOUSE)[0]
|
||||
self.assertEqual(row["actual_qty"], 100)
|
||||
self.assertEqual(row["ordered_qty"], 50)
|
||||
self.assertEqual(row["indented_qty"], 30)
|
||||
@@ -69,7 +71,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
|
||||
self.assertEqual(row["projected_qty"], 172)
|
||||
|
||||
def test_shortage_qty_from_reorder_level(self):
|
||||
item = make_item().name
|
||||
item = "_Test Item"
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.append(
|
||||
"reorder_levels",
|
||||
@@ -83,14 +85,14 @@ class TestStockProjectedQty(ERPNextTestSuite):
|
||||
doc.save()
|
||||
make_stock_entry(item_code=item, qty=10, to_warehouse=WAREHOUSE, basic_rate=100)
|
||||
|
||||
row = self.run_report(item)[0]
|
||||
row = self.run_report(item, warehouse=WAREHOUSE)[0]
|
||||
self.assertEqual(row["re_order_level"], 20)
|
||||
self.assertEqual(row["projected_qty"], 10)
|
||||
self.assertEqual(row["shortage_qty"], 10) # reorder level 20 - projected 10
|
||||
|
||||
def test_item_filter_returns_only_requested_item(self):
|
||||
item_a = make_item().name
|
||||
item_b = make_item().name
|
||||
item_a = "_Test Item"
|
||||
item_b = "_Test Item 2"
|
||||
make_stock_entry(item_code=item_a, qty=5, to_warehouse=WAREHOUSE, basic_rate=100)
|
||||
make_stock_entry(item_code=item_b, qty=7, to_warehouse=WAREHOUSE, basic_rate=100)
|
||||
|
||||
|
||||
@@ -1345,6 +1345,11 @@ class update_entries_after:
|
||||
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return
|
||||
In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount
|
||||
"""
|
||||
if sle.voucher_type == "Stock Reconciliation":
|
||||
if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"):
|
||||
self.update_rate_on_stock_reconciliation(sle)
|
||||
return
|
||||
|
||||
if sle.actual_qty and sle.voucher_detail_no:
|
||||
outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty)
|
||||
|
||||
@@ -1356,8 +1361,6 @@ class update_entries_after:
|
||||
self.update_rate_on_purchase_receipt(sle, outgoing_rate)
|
||||
elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt":
|
||||
self.update_rate_on_subcontracting_receipt(sle, outgoing_rate)
|
||||
elif sle.voucher_type == "Stock Reconciliation":
|
||||
self.update_rate_on_stock_reconciliation(sle)
|
||||
|
||||
def update_rate_on_stock_entry(self, sle, outgoing_rate):
|
||||
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
|
||||
@@ -1451,36 +1454,13 @@ class update_entries_after:
|
||||
d.db_update()
|
||||
|
||||
def update_rate_on_stock_reconciliation(self, sle):
|
||||
if not sle.serial_no and not sle.batch_no:
|
||||
sr = frappe.get_lazy_doc("Stock Reconciliation", sle.voucher_no, for_update=True)
|
||||
|
||||
for item in sr.items:
|
||||
# Skip for Serial and Batch Items
|
||||
if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no:
|
||||
continue
|
||||
|
||||
previous_sle = get_previous_sle(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"warehouse": item.warehouse,
|
||||
"posting_date": sr.posting_date,
|
||||
"posting_time": sr.posting_time,
|
||||
"sle": sle.name,
|
||||
}
|
||||
)
|
||||
|
||||
item.current_qty = previous_sle.get("qty_after_transaction") or 0.0
|
||||
item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0
|
||||
item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate)
|
||||
|
||||
item.amount = flt(item.qty) * flt(item.valuation_rate)
|
||||
item.quantity_difference = item.qty - item.current_qty
|
||||
item.amount_difference = item.amount - item.current_amount
|
||||
sr.difference_amount = sum([item.amount_difference for item in sr.items])
|
||||
sr.db_update()
|
||||
|
||||
for item in sr.items:
|
||||
item.db_update()
|
||||
# Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted
|
||||
# ledger so the document keeps matching the GL entries. Handles serialized, batched and
|
||||
# non-serialized items uniformly (the document method reads the current bundle for serial/batch
|
||||
# rows and the pre-reconciliation ledger balance for non-serial rows).
|
||||
frappe.get_lazy_doc(
|
||||
"Stock Reconciliation", sle.voucher_no, for_update=True
|
||||
).recalculate_difference_amount_from_ledger()
|
||||
|
||||
@staticmethod
|
||||
def get_incoming_value_for_serial_nos(sle, serial_nos):
|
||||
|
||||
Reference in New Issue
Block a user