mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 22:51:49 +00:00
Merge pull request #56651 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
14
.github/workflows/linters.yml
vendored
14
.github/workflows/linters.yml
vendored
@@ -18,7 +18,19 @@ jobs:
|
||||
cache: pip
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.0
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
- name: Download Semgrep rules
|
||||
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
|
||||
|
||||
@@ -50,7 +50,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):
|
||||
@@ -34,8 +35,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()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, nowdate
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import (
|
||||
ClosedAccountingPeriod,
|
||||
@@ -47,7 +47,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})
|
||||
|
||||
@@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
|
||||
filters.append(["date", "<=", to_date])
|
||||
if from_date:
|
||||
filters.append(["date", ">=", from_date])
|
||||
transactions = frappe.get_all(
|
||||
transactions = frappe.get_list(
|
||||
"Bank Transaction",
|
||||
fields=[
|
||||
"date",
|
||||
@@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
|
||||
@frappe.whitelist()
|
||||
def get_account_balance(bank_account, till_date, company):
|
||||
# returns account balance till the specified date
|
||||
frappe.has_permission("Bank Account", "read", bank_account, throw=True)
|
||||
account = frappe.db.get_value("Bank Account", bank_account, "account")
|
||||
filters = frappe._dict(
|
||||
{
|
||||
|
||||
@@ -834,6 +834,7 @@ def resend_payment_email(docname):
|
||||
@frappe.whitelist()
|
||||
def make_payment_entry(docname):
|
||||
doc = frappe.get_doc("Payment Request", docname)
|
||||
doc.check_permission("read")
|
||||
return doc.create_payment_entry(submit=False).as_dict()
|
||||
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
@@ -390,7 +390,6 @@ def get_context(customer, doc):
|
||||
return {
|
||||
"doc": template_doc,
|
||||
"customer": frappe.get_doc("Customer", customer),
|
||||
"frappe": frappe.utils,
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
|
||||
when Is Billing Contact checked
|
||||
and Primary email- email with Is Primary checked"""
|
||||
|
||||
frappe.has_permission("Customer", "read", customer_name, throw=True)
|
||||
|
||||
billing_email = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
@@ -483,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
|
||||
@frappe.whitelist()
|
||||
def download_statements(document_name):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission("read")
|
||||
report = get_report_pdf(doc)
|
||||
if report:
|
||||
frappe.local.response.filename = doc.name + ".pdf"
|
||||
|
||||
@@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_po_is_blocked(self):
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock PI",
|
||||
is_stock_item=0,
|
||||
is_purchase_item=1,
|
||||
).name
|
||||
|
||||
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
|
||||
po.submit()
|
||||
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.items[0].qty = 10 # overbill by 100 %
|
||||
pi.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
pi.submit()
|
||||
|
||||
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
discount_amount = 7
|
||||
|
||||
@@ -3700,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
self.assertTrue("cannot overbill" in str(err.exception).lower())
|
||||
dn.cancel()
|
||||
|
||||
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_is_blocked(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
so = make_sales_order(item_code=service_item, qty=5, rate=100)
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI Quot",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
|
||||
|
||||
so = make_so_from_quotation(quotation.name)
|
||||
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
|
||||
so.insert()
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
|
||||
@@ -562,7 +562,12 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.item_code in product_bundles:
|
||||
if row.is_debit_note:
|
||||
# Rate adjustment debit notes have no stock movement, so buying amount is zero
|
||||
if not grouped_by_invoice:
|
||||
row.qty = 0
|
||||
row.buying_amount = 0
|
||||
elif row.item_code in product_bundles:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -925,6 +930,7 @@ class GrossProfitGenerator:
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoice.is_debit_note,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
@@ -1104,6 +1110,7 @@ class GrossProfitGenerator:
|
||||
"posting_time": row.posting_time,
|
||||
"project": row.project,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"customer": row.customer,
|
||||
"customer_group": row.customer_group,
|
||||
"customer_name": row.customer_name,
|
||||
@@ -1142,6 +1149,7 @@ class GrossProfitGenerator:
|
||||
"description": item.description,
|
||||
"warehouse": item.warehouse or row.warehouse,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"item_group": "",
|
||||
"brand": "",
|
||||
"dn_detail": row.dn_detail,
|
||||
|
||||
@@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase):
|
||||
self.assertEqual(total[7], 1000.0)
|
||||
self.assertEqual(total[8], 100.0)
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
if item_code:
|
||||
dn.items[0].item_code = item_code
|
||||
dn.items[0].item_name = item_code
|
||||
dn.is_debit_note = 1
|
||||
dn.return_against = against_invoice.name
|
||||
dn.items[0].allow_zero_valuation_rate = 1
|
||||
return dn.save().submit()
|
||||
|
||||
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
|
||||
"""
|
||||
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
|
||||
since there is no stock movement. Gross profit equals the adjustment amount
|
||||
and gross profit % equals 100%.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
dn_item_rows = [
|
||||
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
|
||||
]
|
||||
self.assertEqual(len(dn_item_rows), 1)
|
||||
|
||||
dn_row = dn_item_rows[0]
|
||||
self.assertEqual(dn_row.buying_amount, 0.0)
|
||||
self.assertEqual(dn_row.selling_amount, 20.0)
|
||||
self.assertEqual(dn_row.gross_profit, 20.0)
|
||||
self.assertEqual(dn_row["gross_profit_%"], 100.0)
|
||||
|
||||
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
|
||||
"""
|
||||
The original invoice's GP should be derived solely from its own selling
|
||||
amount and COGS — the rate adjustment debit note must not alter it.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
|
||||
self.assertEqual(len(sinv_item_rows), 1)
|
||||
|
||||
sinv_row = sinv_item_rows[0]
|
||||
self.assertEqual(sinv_row.selling_amount, 200.0)
|
||||
self.assertEqual(sinv_row.buying_amount, 100.0)
|
||||
self.assertEqual(sinv_row.gross_profit, 100.0)
|
||||
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
|
||||
|
||||
def test_debit_note_qty_not_inflated_in_grouped_report(self):
|
||||
"""
|
||||
When grouped by Item Code, the debit note (qty=0) must not inflate
|
||||
the group's qty or buying_amount. The selling amount and average
|
||||
selling rate correctly reflect the rate adjustment.
|
||||
"""
|
||||
item = create_item("_Test Rate Adjustment Debit Note Item")
|
||||
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=item.item_code,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_code,
|
||||
cost_center=self.cost_center,
|
||||
warehouse=self.warehouse,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
update_stock=1,
|
||||
currency="INR",
|
||||
income_account=self.income_account,
|
||||
expense_account=self.expense_account,
|
||||
)
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Item Code",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
# group_by="Item Code" column order:
|
||||
# [item_code, item_name, brand, description, qty, base_rate,
|
||||
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
|
||||
item_row = next((row for row in data if row[0] == item.item_code), None)
|
||||
self.assertIsNotNone(item_row)
|
||||
|
||||
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
|
||||
item_row[4],
|
||||
item_row[5],
|
||||
item_row[8],
|
||||
item_row[7],
|
||||
item_row[9],
|
||||
item_row[10],
|
||||
)
|
||||
|
||||
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
|
||||
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
|
||||
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
|
||||
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
|
||||
self.assertEqual(gross_profit, 120.0) # 220 - 100
|
||||
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
|
||||
@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
|
||||
def load_from_db(self):
|
||||
log_detail = qb.DocType("Bulk Transaction Log Detail")
|
||||
|
||||
has_records = frappe.db.sql(
|
||||
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
|
||||
(self.name,),
|
||||
)[0][0]
|
||||
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
|
||||
if not has_records:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ def validate_returned_items(doc):
|
||||
ref.rate
|
||||
and flt(d.rate) > ref.rate
|
||||
and doc.doctype in ("Delivery Note", "Sales Invoice")
|
||||
and get_valuation_method(ref.item_code) != "Moving Average"
|
||||
and get_valuation_method(d.item_code) != "Moving Average"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(
|
||||
|
||||
@@ -135,7 +135,7 @@ status_map = {
|
||||
],
|
||||
[
|
||||
"Partially Ordered",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
],
|
||||
],
|
||||
"POS Opening Entry": [
|
||||
@@ -275,6 +275,12 @@ class StatusUpdater(Document):
|
||||
item["idx"] = d.idx
|
||||
item["target_ref_field"] = args["target_ref_field"].replace("_", " ")
|
||||
|
||||
# skip qty over-allowance check for non-stock items
|
||||
if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value(
|
||||
"Item", item["item_code"], "is_stock_item"
|
||||
):
|
||||
continue
|
||||
|
||||
# if not item[args['target_ref_field']]:
|
||||
# msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code))
|
||||
if args.get("no_allowance"):
|
||||
|
||||
@@ -1490,6 +1490,9 @@ class StockController(AccountsController):
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
if project:
|
||||
gl_entry.update({"project": project})
|
||||
|
||||
if voucher_detail_no:
|
||||
gl_entry.update({"voucher_detail_no": voucher_detail_no})
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ class calculate_taxes_and_totals:
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate"]
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
|
||||
36
erpnext/controllers/tests/test_website_list_for_contact.py
Normal file
36
erpnext/controllers/tests/test_website_list_for_contact.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import json
|
||||
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestWebsiteListForContact(FrappeTestCase):
|
||||
def test_get_list_context_currency_symbols(self):
|
||||
# get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from
|
||||
# raw SQL). Exercises that query and asserts a known enabled currency is present.
|
||||
from erpnext.controllers.website_list_for_contact import get_list_context
|
||||
|
||||
context = get_list_context()
|
||||
|
||||
symbols = json.loads(context["currency_symbols"])
|
||||
self.assertIsInstance(symbols, dict)
|
||||
self.assertIn("USD", symbols)
|
||||
|
||||
def test_rfq_transaction_list_returns_supplier_rfq(self):
|
||||
# rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with
|
||||
# ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the
|
||||
# party value (not a stray `party[0]` column reference).
|
||||
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
|
||||
make_request_for_quotation,
|
||||
)
|
||||
from erpnext.controllers.website_list_for_contact import rfq_transaction_list
|
||||
|
||||
rfq = make_request_for_quotation()
|
||||
supplier = rfq.suppliers[0].supplier
|
||||
|
||||
rows = rfq_transaction_list(
|
||||
"Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20
|
||||
)
|
||||
self.assertIn(rfq.name, [row.name for row in rows])
|
||||
@@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p
|
||||
party = frappe.qb.DocType(parties_doctype)
|
||||
data = (
|
||||
frappe.qb.from_(party)
|
||||
.select(party.parent.as_("name"), party.supplier)
|
||||
# creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list
|
||||
.select(party.parent.as_("name"), party.supplier, party.creation)
|
||||
.distinct()
|
||||
.where((party.supplier == party[0]) & (party.docstatus == 1))
|
||||
.where((party.supplier == parties[0]) & (party.docstatus == 1))
|
||||
.orderby(party.creation, order=frappe.qb.desc)
|
||||
.limit(limit_page_length)
|
||||
.offset(limit_start)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
|
||||
out = frappe._dict()
|
||||
|
||||
lead_doc = frappe.get_doc("Lead", lead)
|
||||
lead_doc.check_permission()
|
||||
lead = lead_doc
|
||||
|
||||
out.update(
|
||||
|
||||
@@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_opportunities(prospect):
|
||||
return frappe.get_all(
|
||||
return frappe.get_list(
|
||||
"Opportunity",
|
||||
filters={"opportunity_from": "Prospect", "party_name": prospect},
|
||||
fields=[
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -1375,7 +1376,7 @@ def get_material_request_items(
|
||||
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
if required_qty > 0:
|
||||
if flt(row.get("qty")) > 0:
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
@@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
|
||||
frappe.qb.from_(table)
|
||||
.inner_join(child)
|
||||
.on(table.name == child.parent)
|
||||
.select(Sum(child.quantity * child.conversion_factor))
|
||||
.select(
|
||||
Sum(
|
||||
(Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity))
|
||||
* child.conversion_factor
|
||||
)
|
||||
)
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (child.item_code == item_code)
|
||||
|
||||
@@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase):
|
||||
quantities = [d["quantity"] for d in mr_items]
|
||||
rm_qty = sum(quantities)
|
||||
|
||||
# Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
|
||||
self.assertEqual(len(mr_items), 2)
|
||||
self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
|
||||
# 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each
|
||||
self.assertEqual(len(mr_items), 3)
|
||||
self.assertEqual(
|
||||
rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)"
|
||||
)
|
||||
self.assertEqual(
|
||||
quantities,
|
||||
[1, 1],
|
||||
"Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
|
||||
[0, 1, 1],
|
||||
"SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1",
|
||||
)
|
||||
|
||||
sr.cancel()
|
||||
@@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase):
|
||||
pln = create_production_plan(
|
||||
item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0
|
||||
)
|
||||
self.assertFalse(len(pln.mr_items))
|
||||
|
||||
items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0]
|
||||
self.assertFalse(len(items_needing_purchase))
|
||||
|
||||
pln.cancel()
|
||||
sr1.cancel()
|
||||
sr2.cancel()
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_sales_orders(self):
|
||||
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
|
||||
|
||||
@@ -16,13 +16,15 @@ erpnext.accounts.taxes = {
|
||||
}
|
||||
});
|
||||
},
|
||||
onload: function(frm) {
|
||||
if(frm.get_field("taxes")) {
|
||||
frm.set_query("account_head", "taxes", function(doc) {
|
||||
if(frm.cscript.tax_table == "Sales Taxes and Charges") {
|
||||
var account_type = ["Tax", "Chargeable", "Expense Account"];
|
||||
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") {
|
||||
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 {
|
||||
|
||||
@@ -498,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
} else if(tax.charge_type == "On Net Total") {
|
||||
if (tax.account_head in item_tax_map) {
|
||||
current_net_amount = item.net_amount
|
||||
};
|
||||
}
|
||||
current_tax_amount = (tax_rate / 100.0) * item.net_amount;
|
||||
} else if(tax.charge_type == "On Previous Row Amount") {
|
||||
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item
|
||||
@@ -862,12 +862,13 @@ 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((grand_total - this.frm.doc.total_advance
|
||||
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),
|
||||
precision("base_grand_total")
|
||||
@@ -901,14 +902,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,
|
||||
|
||||
@@ -1007,13 +1007,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(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) {
|
||||
@@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
let first_row = this.frm.doc.items[0];
|
||||
if (!first_row) {
|
||||
return false
|
||||
};
|
||||
}
|
||||
|
||||
let mapped_rows = mappped_fields.filter(d => first_row[d])
|
||||
|
||||
@@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations");
|
||||
this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations");
|
||||
|
||||
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);
|
||||
@@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items");
|
||||
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items");
|
||||
|
||||
var item_grid = this.frm.fields_dict["scrap_items"].grid;
|
||||
let item_grid = this.frm.fields_dict["scrap_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);
|
||||
@@ -2005,7 +2000,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;
|
||||
@@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
},
|
||||
callback: function(r) {
|
||||
if (!r.exc) {
|
||||
$.each(me.frm.doc.items || [], function(i, item) {
|
||||
if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) {
|
||||
$.each(me.frm.doc.items || [], function (i, item) {
|
||||
if (
|
||||
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;
|
||||
item.item_tax_rate = r.message[item.name].item_tax_rate;
|
||||
me.add_taxes_from_item_tax_template(item.item_tax_rate);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder import Case, Criterion
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
@@ -155,50 +155,60 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_entries(filters):
|
||||
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
|
||||
if filters["doc_type"] == "Sales Order":
|
||||
qty_field = "delivered_qty"
|
||||
else:
|
||||
qty_field = "qty"
|
||||
conditions, values = get_conditions(filters, date_field)
|
||||
doc_type = filters["doc_type"]
|
||||
|
||||
entries = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code,
|
||||
st.sales_person, st.allocated_percentage, dt_item.warehouse,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor
|
||||
ELSE dt_item.stock_qty
|
||||
END as stock_qty,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor)
|
||||
ELSE dt_item.base_net_amount
|
||||
END as base_net_amount,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100)
|
||||
ELSE dt_item.base_net_amount * st.allocated_percentage/100
|
||||
END as contribution_amt
|
||||
FROM
|
||||
`tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st
|
||||
WHERE
|
||||
st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {}
|
||||
and dt.docstatus = 1 {} order by st.sales_person, dt.name desc
|
||||
""".format(
|
||||
date_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
filters["doc_type"],
|
||||
filters["doc_type"],
|
||||
"%s",
|
||||
conditions,
|
||||
),
|
||||
tuple([filters["doc_type"], *values]),
|
||||
as_dict=1,
|
||||
date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date"
|
||||
qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty"
|
||||
|
||||
dt = frappe.qb.DocType(doc_type)
|
||||
dt_item = frappe.qb.DocType(f"{doc_type} Item")
|
||||
st = frappe.qb.DocType("Sales Team")
|
||||
|
||||
calc_qty = dt_item[qty_field] * dt_item.conversion_factor
|
||||
calc_net_amount = dt_item.base_net_rate * calc_qty
|
||||
|
||||
stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
|
||||
|
||||
base_net_amount_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", calc_net_amount)
|
||||
.else_(dt_item.base_net_amount)
|
||||
.as_("base_net_amount")
|
||||
)
|
||||
|
||||
return entries
|
||||
contribution_amt_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100))
|
||||
.else_(dt_item.base_net_amount * st.allocated_percentage / 100)
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
.on(dt.name == st.parent)
|
||||
.select(
|
||||
dt.name,
|
||||
dt.customer,
|
||||
dt.territory,
|
||||
dt[date_field].as_("posting_date"),
|
||||
dt_item.item_code,
|
||||
st.sales_person,
|
||||
st.allocated_percentage,
|
||||
dt_item.warehouse,
|
||||
stock_qty_case,
|
||||
base_net_amount_case,
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
|
||||
@@ -208,7 +208,7 @@ frappe.ui.form.on("Company", {
|
||||
reqd: 1,
|
||||
description: __(
|
||||
"Please make sure you really want to delete all the transactions for {0}. Your master data will remain as it is. This action cannot be undone.",
|
||||
[frappe.utils.bold(frm.doc.name)]
|
||||
[frappe.utils.escape_html(frm.doc.name).bold()]
|
||||
),
|
||||
},
|
||||
function (data) {
|
||||
@@ -228,7 +228,9 @@ frappe.ui.form.on("Company", {
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]),
|
||||
__("Delete all the Transactions for {0}", [
|
||||
frappe.utils.escape_html(frm.doc.name).bold(),
|
||||
]),
|
||||
__("Delete")
|
||||
);
|
||||
d.get_primary_btn().addClass("btn-danger");
|
||||
|
||||
@@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController):
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_rbnb,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
@@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController):
|
||||
against_account=stock_asset_account_name,
|
||||
debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
@@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController):
|
||||
against_account=self.supplier,
|
||||
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
@@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController):
|
||||
against_account=self.supplier,
|
||||
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
@@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController):
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_account_name,
|
||||
account_currency=supplier_warehouse_account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:parameter",
|
||||
"creation": "2020-12-28 17:06:00.254129",
|
||||
"doctype": "DocType",
|
||||
@@ -34,7 +35,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-02-19 20:33:30.657406",
|
||||
"modified": "2026-06-19 10:55:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Quality Inspection Parameter",
|
||||
@@ -93,4 +94,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,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:
|
||||
@@ -306,6 +306,9 @@ class RepostItemValuation(Document):
|
||||
|
||||
def _recalculate_valuation_rate(self):
|
||||
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
|
||||
if doc.get("is_internal_supplier"):
|
||||
doc.set_sales_incoming_rate_for_internal_transfer()
|
||||
|
||||
doc.update_valuation_rate()
|
||||
for item in doc.items:
|
||||
item.db_set("valuation_rate", item.valuation_rate)
|
||||
|
||||
@@ -513,10 +513,12 @@ class SerialandBatchBundle(Document):
|
||||
]
|
||||
|
||||
# Added to handle rejected warehouse case
|
||||
return_warehouse = None
|
||||
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
|
||||
warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
|
||||
if self.warehouse in warehouses:
|
||||
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
|
||||
return_warehouse = self.warehouse
|
||||
filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse])
|
||||
|
||||
bundle_data = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
@@ -529,6 +531,11 @@ class SerialandBatchBundle(Document):
|
||||
order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`",
|
||||
)
|
||||
|
||||
if not bundle_data:
|
||||
bundle_data = self.get_legacy_valuation_rate_for_return_entry(
|
||||
return_against, return_against_voucher_detail_no, return_warehouse
|
||||
)
|
||||
|
||||
if not bundle_data:
|
||||
return {}
|
||||
|
||||
@@ -540,6 +547,49 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
return valuation_details
|
||||
|
||||
def get_legacy_valuation_rate_for_return_entry(
|
||||
self, return_against, return_against_voucher_detail_no, return_warehouse=None
|
||||
):
|
||||
"""Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle."""
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
if not (self.has_serial_no or self.has_batch_no):
|
||||
return []
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.serial_no, sle.batch_no, sle.incoming_rate)
|
||||
.where(
|
||||
(sle.voucher_no == return_against)
|
||||
& (sle.voucher_detail_no == return_against_voucher_detail_no)
|
||||
& (sle.item_code == self.item_code)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (sle.serial_and_batch_bundle.isnull())
|
||||
)
|
||||
)
|
||||
|
||||
if return_warehouse:
|
||||
query = query.where(sle.warehouse == return_warehouse)
|
||||
|
||||
data = []
|
||||
for d in query.run(as_dict=True):
|
||||
if d.serial_no:
|
||||
for serial_no in get_serial_nos(d.serial_no):
|
||||
data.append(
|
||||
frappe._dict(
|
||||
{"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
|
||||
)
|
||||
)
|
||||
elif d.batch_no:
|
||||
data.append(
|
||||
frappe._dict(
|
||||
{"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
|
||||
)
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def calculate_total_qty(self, save=True):
|
||||
self.total_qty = 0.0
|
||||
for d in self.entries:
|
||||
|
||||
@@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase):
|
||||
|
||||
self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name)
|
||||
|
||||
def _assert_legacy_return_valuation(self, item_code, props, batch_no=None):
|
||||
"""Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate."""
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
make_item(item_code, props)
|
||||
if batch_no and not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
|
||||
)
|
||||
|
||||
# Simulate a receipt migrated from an older version: serial nos / batch tracked via the
|
||||
# deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle.
|
||||
serial_nos = []
|
||||
for row in pr.items:
|
||||
if row.serial_and_batch_bundle:
|
||||
serial_nos = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.serial_and_batch_bundle},
|
||||
pluck="serial_no",
|
||||
)
|
||||
frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle})
|
||||
frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None)
|
||||
|
||||
serial_nos = [sn for sn in serial_nos if sn]
|
||||
legacy = {"serial_and_batch_bundle": None}
|
||||
if batch_no:
|
||||
legacy["batch_no"] = batch_no
|
||||
if serial_nos:
|
||||
legacy["serial_no"] = "\n".join(serial_nos)
|
||||
for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"):
|
||||
frappe.db.set_value("Stock Ledger Entry", sle, legacy)
|
||||
|
||||
rt = make_return_doc("Purchase Receipt", pr.name)
|
||||
rt.items[0].qty = -4
|
||||
rt.items[0].received_qty = -4
|
||||
rt.items[0].use_serial_batch_fields = 1
|
||||
if batch_no:
|
||||
rt.items[0].batch_no = batch_no
|
||||
if serial_nos:
|
||||
rt.items[0].serial_no = "\n".join(serial_nos[:4])
|
||||
rt.submit()
|
||||
|
||||
difference_in_stock_value = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"},
|
||||
"stock_value_difference",
|
||||
)
|
||||
# 4 units returned at the original ledger rate of 100 -> -400 (must not be zero)
|
||||
self.assertEqual(flt(difference_in_stock_value, 2), -400.0)
|
||||
|
||||
def test_return_valuation_for_legacy_batch_without_bundle(self):
|
||||
self._assert_legacy_return_valuation(
|
||||
"Test Legacy Batch Return Valuation",
|
||||
{
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "LBRV-.#####",
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
batch_no="LBRV-BATCH-0001",
|
||||
)
|
||||
|
||||
def test_return_valuation_for_legacy_serial_without_bundle(self):
|
||||
self._assert_legacy_return_valuation(
|
||||
"Test Legacy Serial Return Valuation",
|
||||
{"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1},
|
||||
)
|
||||
|
||||
def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self):
|
||||
self._assert_legacy_return_valuation(
|
||||
"Test Legacy Serial Batch Return Valuation",
|
||||
{
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "LSBRV-.#####",
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "LSBRVB-.#####",
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
batch_no="LSBRV-BATCH-0001",
|
||||
)
|
||||
|
||||
|
||||
def get_batch_from_bundle(bundle):
|
||||
from erpnext.stock.serial_batch_bundle import get_batch_nos
|
||||
|
||||
@@ -1257,6 +1257,148 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
|
||||
self.assertEqual(sle[0].qty_after_transaction, 105)
|
||||
self.assertEqual(sle[0].actual_qty, 100)
|
||||
|
||||
def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self):
|
||||
# update_qty_in_future_sle treats "future" as strictly after the current entry in the
|
||||
# (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created
|
||||
# later must still have its running balance shifted; comparing posting_datetime alone would skip
|
||||
# it. The current entry itself (same timestamp, same creation) must not be shifted.
|
||||
from erpnext.stock.stock_ledger import update_qty_in_future_sle
|
||||
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt1 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00",
|
||||
)
|
||||
time.sleep(1)
|
||||
receipt2 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00", # identical timestamp, later creation
|
||||
)
|
||||
|
||||
def sle(voucher):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": voucher.name, "is_cancelled": 0},
|
||||
["name", "posting_date", "posting_time", "creation", "qty_after_transaction"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
sle1, sle2 = sle(receipt1), sle(receipt2)
|
||||
self.assertEqual(sle1.qty_after_transaction, 10)
|
||||
self.assertEqual(sle2.qty_after_transaction, 30)
|
||||
|
||||
# Simulate a +5 qty shift originating at receipt1's ledger position.
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": receipt1.name,
|
||||
"posting_date": sle1.posting_date,
|
||||
"posting_time": sle1.posting_time,
|
||||
"creation": sle1.creation,
|
||||
"actual_qty": 5,
|
||||
}
|
||||
)
|
||||
update_qty_in_future_sle(args, allow_negative_stock=True)
|
||||
|
||||
# receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not.
|
||||
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35)
|
||||
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10)
|
||||
|
||||
def test_cancel_first_of_two_same_timestamp_entries(self):
|
||||
# Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20.
|
||||
# Cancelling the first must leave the second standing alone on a zero base (qty 10), not
|
||||
# double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing,
|
||||
# so update_qty_in_future_sle must not shift it again.
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt1 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date="2026-06-01",
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
time.sleep(1)
|
||||
receipt2 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date="2026-06-01",
|
||||
posting_time="10:00:00", # identical timestamp, later creation
|
||||
)
|
||||
|
||||
def qty_after(voucher):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": voucher.name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
|
||||
self.assertEqual(qty_after(receipt1), 10)
|
||||
self.assertEqual(qty_after(receipt2), 20)
|
||||
|
||||
receipt1.cancel()
|
||||
|
||||
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
|
||||
self.assertEqual(qty_after(receipt2), 10)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
# the entry and must not bound (truncate) the qty-shift range.
|
||||
from erpnext.stock.stock_ledger import get_next_stock_reco
|
||||
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=100,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00",
|
||||
)
|
||||
reco_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": reco.name, "is_cancelled": 0},
|
||||
["posting_date", "posting_time", "creation"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
base_kwargs = {
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"voucher_no": "SOME-OTHER-VOUCHER",
|
||||
"posting_date": reco_sle.posting_date,
|
||||
"posting_time": reco_sle.posting_time,
|
||||
}
|
||||
|
||||
# Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned.
|
||||
after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)}
|
||||
self.assertFalse(get_next_stock_reco(after))
|
||||
|
||||
# Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned.
|
||||
before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)}
|
||||
result = get_next_stock_reco(before)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(result[0].voucher_no, reco.name)
|
||||
|
||||
@change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
|
||||
def test_transfer_invariants(self):
|
||||
"""Extact stock value should be transferred."""
|
||||
|
||||
@@ -993,6 +993,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):
|
||||
"""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(
|
||||
@@ -1179,6 +1275,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,
|
||||
|
||||
@@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, 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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -100,8 +100,6 @@ class StockBalanceReport:
|
||||
self.filters["show_warehouse_wise_stock"] = True
|
||||
item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate()
|
||||
|
||||
_func = itemgetter(1)
|
||||
|
||||
del self.sle_entries
|
||||
|
||||
sre_details = self.get_sre_reserved_qty_details()
|
||||
@@ -126,16 +124,7 @@ class StockBalanceReport:
|
||||
|
||||
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
|
||||
if opening_fifo_queue:
|
||||
fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func)
|
||||
fifo_queue = normalize_fifo_queue(fifo_queue)
|
||||
if not fifo_queue:
|
||||
continue
|
||||
|
||||
to_date = self.to_date
|
||||
stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
|
||||
stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
|
||||
stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
|
||||
stock_ageing_data["fifo_queue"] = fifo_queue
|
||||
stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date))
|
||||
|
||||
report_data.update(stock_ageing_data)
|
||||
|
||||
@@ -694,6 +683,21 @@ class StockBalanceReport:
|
||||
return opening_fifo_queue
|
||||
|
||||
|
||||
def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict:
|
||||
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
|
||||
fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1))
|
||||
|
||||
if not fifo_queue:
|
||||
return stock_ageing_data
|
||||
|
||||
stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
|
||||
stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
|
||||
stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
|
||||
stock_ageing_data["fifo_queue"] = fifo_queue
|
||||
|
||||
return stock_ageing_data
|
||||
|
||||
|
||||
def filter_items_with_no_transactions(
|
||||
iwb_map, float_precision: float, inventory_dimensions: list | None = None
|
||||
):
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe.utils import 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_balance.stock_balance import execute
|
||||
from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data
|
||||
|
||||
|
||||
def stock_balance(filters):
|
||||
@@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase):
|
||||
rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]}))
|
||||
self.assertPartialDictEq(attributes, rows[0])
|
||||
self.assertInvariants(rows)
|
||||
|
||||
def test_stock_ageing_data_accepts_batchwise_valuation_slots(self):
|
||||
fifo_queue = [
|
||||
["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0],
|
||||
["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0],
|
||||
]
|
||||
|
||||
stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10")
|
||||
|
||||
self.assertEqual(stock_ageing_data["average_age"], 7.4)
|
||||
self.assertEqual(stock_ageing_data["earliest_age"], 9)
|
||||
self.assertEqual(stock_ageing_data["latest_age"], 5)
|
||||
self.assertEqual(
|
||||
stock_ageing_data["fifo_queue"],
|
||||
[[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]],
|
||||
)
|
||||
|
||||
@@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = {
|
||||
},
|
||||
],
|
||||
onload: function (report) {
|
||||
report.page.add_inner_button(__("Update Batch Qty"), function () {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes
|
||||
.map((i) => frappe.query_report.data[i])
|
||||
.filter((row) => row.difference != 0);
|
||||
if (frappe.model.can_write("Batch")) {
|
||||
report.page.add_inner_button(__("Update Batch Qty"), function () {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes
|
||||
.map((i) => frappe.query_report.data[i])
|
||||
.filter((row) => row.difference != 0);
|
||||
|
||||
if (selected_rows.length) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
|
||||
args: {
|
||||
selected_batches: selected_rows,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
report.refresh();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint(__("Please select at least one row with difference value"));
|
||||
}
|
||||
});
|
||||
if (selected_rows.length) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
|
||||
args: {
|
||||
selected_batches: selected_rows,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
report.refresh();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint(__("Please select at least one row with difference value"));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
formatter: function (value, row, column, data, default_formatter) {
|
||||
|
||||
@@ -101,6 +101,7 @@ def get_data(filters=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_batch_qty(selected_batches=None):
|
||||
frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True)
|
||||
if not selected_batches:
|
||||
return
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou
|
||||
"sle_id": args.get("name"),
|
||||
"creation": args.get("creation"),
|
||||
"reserved_stock": args.get("reserved_stock"),
|
||||
"cancelled": args.get("is_cancelled"),
|
||||
},
|
||||
allow_negative_stock=allow_negative_stock,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
@@ -1329,6 +1330,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)
|
||||
|
||||
@@ -1340,8 +1346,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)
|
||||
@@ -1435,37 +1439,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_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
|
||||
else:
|
||||
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()
|
||||
|
||||
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
|
||||
# get rate from serial nos within same company
|
||||
@@ -2062,36 +2042,47 @@ def get_valuation_rate(
|
||||
|
||||
def update_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
|
||||
datetime_limit_condition = ""
|
||||
qty_shift = args.actual_qty
|
||||
|
||||
args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
|
||||
posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"])
|
||||
args["posting_datetime"] = posting_datetime
|
||||
|
||||
# find difference/shift in qty caused by stock reconciliation
|
||||
if args.voucher_type == "Stock Reconciliation":
|
||||
qty_shift = get_stock_reco_qty_shift(args)
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
# SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the
|
||||
# current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a
|
||||
# later creation. Comparing posting_datetime alone would skip same-timestamp entries created after
|
||||
# this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second).
|
||||
# On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time
|
||||
# (not the original entry's position) and same-timestamp siblings are already recomputed by the
|
||||
# cancelled path in update_entries_after; applying the tiebreaker here would double-shift them.
|
||||
future_condition = sle.posting_datetime > posting_datetime
|
||||
if args.get("creation") and not args.get("is_cancelled"):
|
||||
future_condition = future_condition | (
|
||||
(sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation"))
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.update(sle)
|
||||
.set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift)
|
||||
.where(
|
||||
(sle.item_code == args.get("item_code"))
|
||||
& (sle.warehouse == args.get("warehouse"))
|
||||
& (sle.is_cancelled == 0)
|
||||
& future_condition
|
||||
)
|
||||
)
|
||||
|
||||
# find the next nearest stock reco so that we only recalculate SLEs till that point
|
||||
next_stock_reco_detail = get_next_stock_reco(args)
|
||||
if next_stock_reco_detail:
|
||||
detail = next_stock_reco_detail[0]
|
||||
datetime_limit_condition = get_datetime_limit_condition(detail)
|
||||
query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0]))
|
||||
|
||||
frappe.db.sql( # nosemgrep
|
||||
f"""
|
||||
update `tabStock Ledger Entry`
|
||||
set qty_after_transaction = qty_after_transaction + {qty_shift}
|
||||
where
|
||||
item_code = %(item_code)s
|
||||
and warehouse = %(warehouse)s
|
||||
and is_cancelled = 0
|
||||
and (
|
||||
posting_datetime > %(posting_datetime)s
|
||||
)
|
||||
{datetime_limit_condition}
|
||||
""",
|
||||
args,
|
||||
)
|
||||
query.run()
|
||||
|
||||
validate_negative_qty_in_future_sle(args, allow_negative_stock)
|
||||
|
||||
@@ -2126,6 +2117,22 @@ def get_stock_reco_qty_shift(args):
|
||||
return stock_reco_qty_shift
|
||||
|
||||
|
||||
def get_next_reco_datetime_condition(sle, kwargs):
|
||||
"""Match stock recos that come strictly after the current entry in (posting_datetime, creation)
|
||||
order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created
|
||||
earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range."""
|
||||
current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
|
||||
|
||||
creation = kwargs.get("creation")
|
||||
if not creation:
|
||||
# No creation tiebreaker available; fall back to the posting_datetime-only bound.
|
||||
return sle.posting_datetime >= current_datetime
|
||||
|
||||
return (sle.posting_datetime > current_datetime) | (
|
||||
(sle.posting_datetime == current_datetime) & (sle.creation > creation)
|
||||
)
|
||||
|
||||
|
||||
def get_next_stock_reco(kwargs):
|
||||
"""Returns next nearest stock reconciliaton's details."""
|
||||
|
||||
@@ -2151,10 +2158,7 @@ def get_next_stock_reco(kwargs):
|
||||
& (sle.voucher_type == "Stock Reconciliation")
|
||||
& (sle.voucher_no != kwargs.get("voucher_no"))
|
||||
& (sle.is_cancelled == 0)
|
||||
& (
|
||||
sle.posting_datetime
|
||||
>= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
|
||||
)
|
||||
& get_next_reco_datetime_condition(sle, kwargs)
|
||||
)
|
||||
.orderby(sle.posting_datetime)
|
||||
.orderby(sle.creation)
|
||||
@@ -2167,17 +2171,12 @@ def get_next_stock_reco(kwargs):
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_datetime_limit_condition(detail):
|
||||
def get_datetime_limit_condition(sle, detail):
|
||||
posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time)
|
||||
|
||||
return f"""
|
||||
and
|
||||
(posting_datetime < '{posting_datetime}'
|
||||
or (
|
||||
posting_datetime = '{posting_datetime}'
|
||||
and creation < '{detail.creation}'
|
||||
)
|
||||
)"""
|
||||
return (sle.posting_datetime < posting_datetime) | (
|
||||
(sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation)
|
||||
)
|
||||
|
||||
|
||||
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
|
||||
Reference in New Issue
Block a user