mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-09 08:22:25 +00:00
Compare commits
9 Commits
version-15
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f784612d | ||
|
|
457424f7a4 | ||
|
|
25ee3695f0 | ||
|
|
ff205da810 | ||
|
|
2980171007 | ||
|
|
c6c4815e8d | ||
|
|
82a85818c2 | ||
|
|
57a2be6b56 | ||
|
|
47f54a4725 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.112.0"
|
||||
__version__ = "15.115.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -115,36 +115,6 @@ class TestBankTransaction(FrappeTestCase):
|
||||
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
||||
self.assertEqual(bank_transaction.payment_entries, [])
|
||||
|
||||
# Amending a reconciled payment entry must not carry over its clearance date
|
||||
def test_clearance_date_cleared_on_amend(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
"Bank Transaction",
|
||||
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
|
||||
)
|
||||
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
|
||||
vouchers = json.dumps(
|
||||
[
|
||||
{
|
||||
"payment_doctype": "Payment Entry",
|
||||
"payment_name": payment.name,
|
||||
"amount": bank_transaction.unallocated_amount,
|
||||
}
|
||||
]
|
||||
)
|
||||
reconcile_vouchers(bank_transaction.name, vouchers)
|
||||
|
||||
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
|
||||
|
||||
payment.reload()
|
||||
payment.cancel()
|
||||
|
||||
amended = frappe.copy_doc(payment)
|
||||
amended.amended_from = payment.name
|
||||
amended.docstatus = 0
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(amended.clearance_date)
|
||||
|
||||
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
||||
def test_debit_credit_output(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
|
||||
@@ -40,10 +40,6 @@ frappe.ui.form.on("Journal Entry", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
frm.set_read_only();
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
|
||||
if (frm.doc.docstatus > 0) {
|
||||
|
||||
@@ -21,7 +21,6 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
|
||||
get_party_tax_withholding_details,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
@@ -124,9 +123,6 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
|
||||
@@ -796,17 +796,10 @@ class PaymentReconciliation(Document):
|
||||
|
||||
|
||||
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
|
||||
)
|
||||
for inv in dr_cr_notes:
|
||||
if (
|
||||
flt(
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
- inv.allocated_amount,
|
||||
allocated_amount_precision,
|
||||
)
|
||||
< 0
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
< inv.allocated_amount
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
|
||||
|
||||
@@ -155,7 +155,6 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
sinv = create_sales_invoice(
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
posting_date=posting_date,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -2147,7 +2146,7 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
pr.reconcile()
|
||||
|
||||
si.reload()
|
||||
self.assertEqual(si.status, "Overdue")
|
||||
self.assertEqual(si.status, "Partly Paid")
|
||||
# check PR tool output post reconciliation
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
|
||||
@@ -2541,76 +2540,6 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_cr_note_split_across_invoices_floating_point_precision(self):
|
||||
"""Regression: when a credit note is split across multiple invoices, floating-point
|
||||
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
|
||||
|
||||
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
|
||||
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
|
||||
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
|
||||
"""
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
|
||||
# (invoices are sorted by posting_date ascending, so si_a is processed first).
|
||||
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
|
||||
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
|
||||
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
|
||||
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
|
||||
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
|
||||
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
|
||||
|
||||
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
|
||||
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
|
||||
# by subtracting a decimal-valued payment from the integer total:
|
||||
# 100 - 91.55 = 8.45
|
||||
# 200 - 109.28 = 90.72
|
||||
# 100 - 27.43 = 72.57
|
||||
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.paid_amount = partial_paid
|
||||
pe.received_amount = partial_paid
|
||||
pe.references[0].allocated_amount = partial_paid
|
||||
pe.save().submit()
|
||||
|
||||
cr_note = self.create_sales_invoice(
|
||||
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
cr_note.is_return = 1
|
||||
cr_note = cr_note.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
# Widen date range so all three invoices (oldest is -2 days) are fetched
|
||||
pr.from_invoice_date = add_days(nowdate(), -2)
|
||||
pr.to_invoice_date = nowdate()
|
||||
pr.from_payment_date = nowdate()
|
||||
pr.to_payment_date = nowdate()
|
||||
|
||||
pr.get_unreconciled_entries()
|
||||
self.assertEqual(len(pr.invoices), 3)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
|
||||
self.assertEqual(len(pr.allocation), 3)
|
||||
last_row = pr.allocation[-1]
|
||||
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
|
||||
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
|
||||
|
||||
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
|
||||
pr.reconcile()
|
||||
|
||||
si_a.reload()
|
||||
si_b.reload()
|
||||
si_c.reload()
|
||||
self.assertEqual(si_a.outstanding_amount, 0)
|
||||
self.assertEqual(si_b.outstanding_amount, 0)
|
||||
# si_c is only partially settled: 72.57 - 50.83 = 21.74
|
||||
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
|
||||
|
||||
|
||||
def make_customer(customer_name, currency=None):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
|
||||
@@ -379,15 +379,12 @@ class TestPeriodClosingVoucher(unittest.TestCase):
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
|
||||
|
||||
try:
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -156,24 +156,6 @@ class PricingRule(Document):
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
|
||||
if self.apply_on == "Item Code":
|
||||
self.validate_template_with_variant(values)
|
||||
|
||||
def validate_template_with_variant(self, item_codes):
|
||||
# throws if a template and its variant both exist in one rule
|
||||
variants = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
|
||||
fields=["name", "variant_of"],
|
||||
)
|
||||
if variants:
|
||||
variant = variants[0]
|
||||
frappe.throw(
|
||||
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
|
||||
frappe.bold(variant.name), frappe.bold(variant.variant_of)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if self.has_priority and not self.priority:
|
||||
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))
|
||||
|
||||
@@ -336,31 +336,6 @@ class TestPricingRule(FrappeTestCase):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 17.5)
|
||||
|
||||
def test_pricing_rule_with_template_and_its_variant(self):
|
||||
if not frappe.db.exists("Item", "Test Variant PRT"):
|
||||
variant = frappe.new_doc("Item")
|
||||
variant.item_code = "Test Variant PRT"
|
||||
variant.item_name = "Test Variant PRT"
|
||||
variant.item_group = "_Test Item Group"
|
||||
variant.is_stock_item = 1
|
||||
variant.variant_of = "_Test Variant Item"
|
||||
variant.stock_uom = "_Test UOM"
|
||||
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
|
||||
variant.insert()
|
||||
|
||||
rule = frappe.new_doc("Pricing Rule")
|
||||
rule.title = "_Test Pricing Rule Template Variant"
|
||||
rule.apply_on = "Item Code"
|
||||
rule.currency = "USD"
|
||||
rule.selling = 1
|
||||
rule.rate_or_discount = "Discount Percentage"
|
||||
rule.discount_percentage = 10
|
||||
rule.company = "_Test Company"
|
||||
rule.append("items", {"item_code": "_Test Variant Item"})
|
||||
rule.append("items", {"item_code": "Test Variant PRT"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, rule.insert)
|
||||
|
||||
def test_pricing_rule_for_stock_qty(self):
|
||||
test_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
|
||||
@@ -86,55 +86,50 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
def initialize_parallel_threads(docname: str):
|
||||
threads = 4
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(threads)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
x.name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=x.name,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@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 Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
initialize_parallel_threads(docname)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -252,11 +247,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
@@ -266,15 +261,15 @@ def schedule_next_date(docname: str):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
to_process[0].name,
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
@@ -282,7 +277,6 @@ def schedule_next_date(docname: str):
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=to_process[0].name,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
@@ -447,11 +441,6 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
@@ -537,10 +526,10 @@ def build_dimension_wise_balance_dict(gl_entries):
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
@@ -587,20 +576,17 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -24,10 +24,3 @@ class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index(
|
||||
"Process Period Closing Voucher Detail",
|
||||
["parent", "status", "parentfield", "idx", "processing_date"],
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -697,15 +697,13 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
else:
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
|
||||
check_freezing_date(validation_date, adv_adj)
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
@@ -772,7 +770,7 @@ def make_reverse_gl_entries(
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
@@ -804,24 +802,13 @@ def check_freezing_date(posting_date, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
# Local import so you don't have to touch file-level imports
|
||||
from frappe.query_builder.functions import Max
|
||||
|
||||
|
||||
@@ -141,26 +141,6 @@ class AccountsController(TransactionBase):
|
||||
if self.doctype in relevant_docs:
|
||||
self.set_payment_schedule()
|
||||
|
||||
def before_insert(self):
|
||||
self.clear_clearance_date_on_amend()
|
||||
|
||||
def clear_clearance_date_on_amend(self):
|
||||
"""Drop the bank reconciliation clearance date copied over while amending.
|
||||
|
||||
The framework copies `no_copy` fields when amending, so a reconciled
|
||||
voucher would carry a stale clearance date into its amendment even though
|
||||
the linked bank transaction gets unreconciled on cancellation.
|
||||
"""
|
||||
if not self.get("amended_from"):
|
||||
return
|
||||
|
||||
if self.meta.has_field("clearance_date"):
|
||||
self.clearance_date = None
|
||||
|
||||
for payment in self.get("payments") or []:
|
||||
if payment.meta.has_field("clearance_date"):
|
||||
payment.clearance_date = None
|
||||
|
||||
def remove_bundle_for_non_stock_invoices(self):
|
||||
has_sabb = False
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:
|
||||
|
||||
@@ -263,10 +263,6 @@ class StockController(AccountsController):
|
||||
parent_details = self.get_parent_details_for_packed_items()
|
||||
|
||||
for row in self.get(table_name):
|
||||
item_code = row.get("rm_item_code") or row.get("item_code")
|
||||
if not item_code or not self.is_serial_batch_item(item_code):
|
||||
continue
|
||||
|
||||
if (
|
||||
not via_landed_cost_voucher
|
||||
and row.serial_and_batch_bundle
|
||||
|
||||
@@ -687,28 +687,6 @@ class TestWorkOrder(FrappeTestCase):
|
||||
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
|
||||
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
|
||||
|
||||
@change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
|
||||
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
|
||||
# "_Test Item Group" is master data with buying_cost_center already set to
|
||||
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
|
||||
# BOM need to be created, since no existing item in that group has one.
|
||||
fg_item = make_item(
|
||||
"_Test FG Item For Item Group Cost Center",
|
||||
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
|
||||
)
|
||||
|
||||
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
|
||||
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
|
||||
|
||||
wo_order = make_wo_order_test_record(
|
||||
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
|
||||
)
|
||||
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
|
||||
ste.insert()
|
||||
|
||||
fg_row = next(d for d in ste.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
|
||||
|
||||
def test_operation_time_with_batch_size(self):
|
||||
fg_item = "Test Batch Size Item For BOM"
|
||||
rm1 = "Test Batch Size Item RM 1 For BOM"
|
||||
|
||||
@@ -262,6 +262,7 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
|
||||
erpnext.patches.v14_0.update_proprietorship_to_individual
|
||||
erpnext.patches.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets
|
||||
@@ -421,7 +422,6 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo
|
||||
erpnext.patches.v15_0.add_company_payment_gateway_account
|
||||
erpnext.patches.v15_0.update_uae_zero_rated_fetch
|
||||
erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
|
||||
@@ -438,5 +438,3 @@ erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
|
||||
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
|
||||
erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
|
||||
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
for custom_field in frappe.get_all(
|
||||
"Custom Field",
|
||||
filters={
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "dt"],
|
||||
):
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False
|
||||
)
|
||||
frappe.clear_cache(doctype=custom_field.dt)
|
||||
@@ -1,20 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
for docfield in frappe.get_all(
|
||||
"DocField",
|
||||
filters={
|
||||
"parenttype": "DocType",
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "parent"],
|
||||
):
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
|
||||
)
|
||||
frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False)
|
||||
frappe.clear_cache(doctype=docfield.parent)
|
||||
@@ -330,48 +330,33 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off Cost Center",
|
||||
"no_copy": 1,
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "write_off_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Write Off Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "exchange_gain_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Exchange Gain / Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "unrealized_exchange_gain_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Unrealized Exchange Gain/Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -497,7 +482,6 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "expenses_included_in_valuation",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
@@ -506,19 +490,15 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "accumulated_depreciation_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Accumulated Depreciation Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "depreciation_expense_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Depreciation Expense Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
@@ -539,39 +519,29 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "disposal_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Gain/Loss Account on Asset Disposal",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "depreciation_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Asset Depreciation Cost Center",
|
||||
"no_copy": 1,
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "capital_work_in_progress_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Capital Work In Progress Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "asset_received_but_not_billed",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Asset Received But Not Billed",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -703,21 +673,15 @@
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "unrealized_profit_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Unrealized Profit / Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "default_discount_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Payment Discount Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -759,10 +723,8 @@
|
||||
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
|
||||
"fieldname": "default_advance_received_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Advance Received Account",
|
||||
"mandatory_depends_on": "book_advance_payments_as_liability",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -771,10 +733,8 @@
|
||||
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
|
||||
"fieldname": "default_advance_paid_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Advance Paid Account",
|
||||
"mandatory_depends_on": "book_advance_payments_as_liability",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -854,12 +814,9 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_for_opening",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off for Opening",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -908,7 +865,7 @@
|
||||
"image_field": "company_logo",
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-02 07:21:21.794533",
|
||||
"modified": "2025-11-16 16:51:27.624096",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Company",
|
||||
|
||||
@@ -74,7 +74,6 @@ class Company(NestedSet):
|
||||
default_operating_cost_account: DF.Link | None
|
||||
default_payable_account: DF.Link | None
|
||||
default_provisional_account: DF.Link | None
|
||||
default_purchase_price_variance_account: DF.Link | None
|
||||
default_receivable_account: DF.Link | None
|
||||
default_sales_contact: DF.Link | None
|
||||
default_selling_terms: DF.Link | None
|
||||
|
||||
@@ -9,7 +9,6 @@ from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_company_address
|
||||
from frappe.contacts.doctype.contact.contact import get_default_contact
|
||||
from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder import DocType
|
||||
@@ -911,9 +910,7 @@ def get_returned_qty_map(delivery_note):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(
|
||||
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
|
||||
):
|
||||
def make_sales_invoice(source_name, target_doc=None, args=None):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
@@ -1018,12 +1015,7 @@ def make_sales_invoice(
|
||||
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
|
||||
if doc.is_return:
|
||||
# A credit note made from a return Delivery Note should roll back the billed
|
||||
# amount on the linked Sales Order too, so that per_billed stays consistent with
|
||||
# per_delivered (which the return already reset).
|
||||
doc.update_billed_amount_in_sales_order = True
|
||||
else:
|
||||
if not doc.is_return:
|
||||
so, doctype, fieldname = doc.get_order_details()
|
||||
if (
|
||||
doc.linked_order_has_payment_terms(so, fieldname, doctype)
|
||||
|
||||
@@ -2599,92 +2599,6 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
self.assertEqual(dn.per_returned, 100)
|
||||
self.assertEqual(returned.status, "Return")
|
||||
|
||||
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
|
||||
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
|
||||
a credit note made from the return of that Delivery Note must reset per_billed to 0
|
||||
while leaving the delivery quantities exactly as the return already set them."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_delivered, 100)
|
||||
self.assertEqual(so.per_billed, 100)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.insert()
|
||||
return_dn.submit()
|
||||
|
||||
# the return reverses the delivery quantities
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_delivered, 0)
|
||||
self.assertEqual(so.items[0].delivered_qty, 0)
|
||||
|
||||
credit_note = make_sales_invoice(return_dn.name)
|
||||
self.assertTrue(credit_note.is_return)
|
||||
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
|
||||
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
|
||||
# credit note only rolls back billing and never re-reverses the delivery quantities.
|
||||
self.assertFalse(credit_note.update_stock)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
|
||||
# per_billed is reset, and the delivery state stays exactly as the return left it
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_billed, 0)
|
||||
self.assertEqual(so.per_delivered, 0)
|
||||
self.assertEqual(so.items[0].delivered_qty, 0)
|
||||
self.assertEqual(so.items[0].returned_qty, 0)
|
||||
|
||||
# Cancelling the credit note should restore the billed amount on the Sales Order.
|
||||
credit_note.cancel()
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_billed, 100)
|
||||
|
||||
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
|
||||
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
|
||||
# The DN carries si_detail in this path.
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
|
||||
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
|
||||
|
||||
so = make_sales_order(qty=2)
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn = make_delivery_note(si.name)
|
||||
dn.insert()
|
||||
dn.submit()
|
||||
|
||||
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
|
||||
|
||||
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
|
||||
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
|
||||
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
make_delivery_note as make_dn_from_so,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
make_sales_invoice as make_si_from_so,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
|
||||
|
||||
so = make_sales_order(qty=2)
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn = make_dn_from_so(so.name)
|
||||
dn.insert()
|
||||
dn.submit()
|
||||
|
||||
self.assertIsNone(dn.items[0].si_detail)
|
||||
|
||||
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
|
||||
|
||||
def test_sales_return_for_product_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
@@ -217,7 +217,6 @@ class Item(Document):
|
||||
self.validate_item_defaults()
|
||||
self.validate_auto_reorder_enabled_in_stock_settings()
|
||||
self.cant_change()
|
||||
self.validate_serialized_change_with_bundle()
|
||||
self.validate_item_tax_net_rate_range()
|
||||
|
||||
if not self.is_new():
|
||||
@@ -1075,25 +1074,6 @@ class Item(Document):
|
||||
|
||||
frappe.throw(msg, title=_("Linked with submitted documents"))
|
||||
|
||||
def validate_serialized_change_with_bundle(self):
|
||||
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
|
||||
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
|
||||
if self.is_new() or self.has_serial_no or not self._doc_before_save:
|
||||
return
|
||||
|
||||
# Only relevant when the item was serialized before and is now being unset.
|
||||
if not self._doc_before_save.has_serial_no:
|
||||
return
|
||||
|
||||
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
|
||||
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
|
||||
).format(frappe.bold(self.name)),
|
||||
title=_("Serial and Batch Bundle Exists"),
|
||||
)
|
||||
|
||||
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
|
||||
linked_doctypes = [
|
||||
"Delivery Note Item",
|
||||
|
||||
@@ -966,47 +966,6 @@ class TestItem(FrappeTestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, item_doc.save)
|
||||
|
||||
def test_cannot_unset_serialized_while_bundle_exists(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
|
||||
item = make_item(
|
||||
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
|
||||
).name
|
||||
|
||||
serial_no = f"{item}-SN-01"
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
|
||||
).insert()
|
||||
|
||||
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
|
||||
bundle = make_serial_batch_bundle(
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"company": "_Test Company",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"voucher_type": "Stock Entry",
|
||||
"serial_nos": [serial_no],
|
||||
"type_of_transaction": "Inward",
|
||||
"do_not_submit": True,
|
||||
"ignore_sabb_validation": True,
|
||||
}
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
# Once the bundle is removed, the item can be made non-serialized.
|
||||
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
doc.save()
|
||||
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
|
||||
|
||||
|
||||
def set_item_variant_settings(fields):
|
||||
doc = frappe.get_doc("Item Variant Settings")
|
||||
|
||||
@@ -1662,93 +1662,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
self.assertEqual(query[0].value, 0)
|
||||
|
||||
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
|
||||
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
|
||||
PR.item.valuation_rate was wrong at submit, so divisional_loss does not
|
||||
leak to COGS."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.stock_ledger import update_entries_after
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
customer = "_Test Internal Customer 2"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Drift From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
|
||||
item_doc = create_item("Test Internal Drift Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name,
|
||||
company=company,
|
||||
posting_date=add_days(today(), -1),
|
||||
warehouse=from_warehouse,
|
||||
qty=10,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=item_doc.name,
|
||||
company=company,
|
||||
customer=customer,
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
qty=1,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
target_warehouse=transit_warehouse,
|
||||
)
|
||||
self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
|
||||
|
||||
pr = make_inter_company_purchase_receipt(dn.name)
|
||||
pr.items[0].warehouse = to_warehouse
|
||||
pr.submit()
|
||||
|
||||
inward_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"warehouse": to_warehouse,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(inward_sle.incoming_rate), 100.0)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Purchase Receipt Item",
|
||||
pr.items[0].name,
|
||||
{"sales_incoming_rate": 0, "valuation_rate": 80},
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
inward_sle.name,
|
||||
{"incoming_rate": 80, "stock_value_difference": 80},
|
||||
)
|
||||
|
||||
update_entries_after(
|
||||
{
|
||||
"item_code": inward_sle.item_code,
|
||||
"warehouse": inward_sle.warehouse,
|
||||
"posting_date": inward_sle.posting_date,
|
||||
"posting_time": inward_sle.posting_time,
|
||||
"sle_id": inward_sle.name,
|
||||
"creation": inward_sle.creation,
|
||||
}
|
||||
)
|
||||
|
||||
refreshed = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
inward_sle.name,
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(refreshed.incoming_rate), 100.0)
|
||||
self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
|
||||
|
||||
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
|
||||
self,
|
||||
):
|
||||
|
||||
@@ -337,6 +337,7 @@
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": ":Company",
|
||||
"depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))",
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
@@ -615,7 +616,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-03 12:11:53.714931",
|
||||
"modified": "2026-04-27 11:40:38.294196",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
@@ -12,7 +12,6 @@ from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json
|
||||
|
||||
import erpnext
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.doctype.pricing_rule.pricing_rule import (
|
||||
get_pricing_rule_for_item,
|
||||
@@ -411,26 +410,12 @@ def get_basic_details(args, item, overwrite_warehouse=True):
|
||||
|
||||
expense_account = None
|
||||
|
||||
if item.is_fixed_asset:
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
|
||||
if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset:
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
|
||||
if is_cwip_accounting_enabled(item.asset_category):
|
||||
expense_account = get_asset_account(
|
||||
"capital_work_in_progress_account",
|
||||
asset_category=item.asset_category,
|
||||
company=args.company,
|
||||
)
|
||||
elif args.get("doctype") in (
|
||||
"Purchase Invoice",
|
||||
"Purchase Receipt",
|
||||
"Purchase Order",
|
||||
"Material Request",
|
||||
):
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
|
||||
expense_account = get_asset_category_account(
|
||||
fieldname="fixed_asset_account", item=args.item_code, company=args.company
|
||||
)
|
||||
expense_account = get_asset_category_account(
|
||||
fieldname="fixed_asset_account", item=args.item_code, company=args.company
|
||||
)
|
||||
|
||||
# Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master
|
||||
if not args.get("uom"):
|
||||
@@ -533,21 +518,10 @@ def get_basic_details(args, item, overwrite_warehouse=True):
|
||||
args.name, args.conversion_rate, item.name, out.conversion_factor
|
||||
)
|
||||
|
||||
expense_account_field = "default_expense_account"
|
||||
if (
|
||||
item.is_stock_item
|
||||
and erpnext.is_perpetual_inventory_enabled(args.company)
|
||||
and (
|
||||
args.doctype == "Purchase Receipt"
|
||||
or (args.doctype == "Purchase Invoice" and args.get("update_stock"))
|
||||
)
|
||||
):
|
||||
expense_account_field = "stock_received_but_not_billed"
|
||||
|
||||
# if default specified in item is for another company, fetch from company
|
||||
for d in [
|
||||
["Account", "income_account", "default_income_account"],
|
||||
["Account", "expense_account", expense_account_field],
|
||||
["Account", "expense_account", "default_expense_account"],
|
||||
["Cost Center", "cost_center", "cost_center"],
|
||||
["Warehouse", "warehouse", ""],
|
||||
]:
|
||||
|
||||
@@ -492,7 +492,7 @@ class FIFOSlots:
|
||||
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
|
||||
elif batch_nos and row.get("has_batch_no"):
|
||||
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
|
||||
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
|
||||
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
|
||||
self._add_to_negative_fifo_head(row, fifo_queue)
|
||||
else:
|
||||
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])
|
||||
|
||||
@@ -1438,47 +1438,6 @@ class TestStockAgeing(FrappeTestCase):
|
||||
self.assertEqual(item_result["total_qty"], -4.0)
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
|
||||
|
||||
def test_untagged_receipt_with_negative_batch_head(self):
|
||||
"""An incoming SLE without batch details must not treat a negative
|
||||
batch slot at the queue head as a qty slot (TypeError: str += float)."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Enclosure Item",
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no="QI-06448",
|
||||
),
|
||||
frappe._dict(
|
||||
name="Enclosure Item",
|
||||
actual_qty=45,
|
||||
qty_after_transaction=35,
|
||||
stock_value_difference=1051.65,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-05",
|
||||
voucher_type="Purchase Receipt",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
batch_no=None,
|
||||
serial_and_batch_bundle="SABB-00001294",
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots["Enclosure Item"]["fifo_queue"]
|
||||
|
||||
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
|
||||
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
|
||||
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
|
||||
|
||||
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ SLE_FIELDS = (
|
||||
"outgoing_rate",
|
||||
"stock_queue",
|
||||
"batch_no",
|
||||
"serial_no",
|
||||
"stock_value",
|
||||
"stock_value_difference",
|
||||
"valuation_rate",
|
||||
@@ -53,16 +52,16 @@ def add_invariant_check_fields(sles, filters):
|
||||
balance_qty = 0.0
|
||||
balance_stock_value = 0.0
|
||||
|
||||
incorrect_idx = None
|
||||
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
|
||||
currency_precision = (
|
||||
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
|
||||
)
|
||||
incorrect_idx = 0
|
||||
precision = frappe.get_precision("Stock Ledger Entry", "actual_qty")
|
||||
for idx, sle in enumerate(sles):
|
||||
if sle.batch_no:
|
||||
sle.use_batchwise_valuation = frappe.db.get_value(
|
||||
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
|
||||
)
|
||||
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
|
||||
|
||||
fifo_qty = 0.0
|
||||
fifo_value = 0.0
|
||||
for qty, rate in queue:
|
||||
fifo_qty += qty
|
||||
fifo_value += qty * rate
|
||||
|
||||
if sle.actual_qty < 0:
|
||||
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
|
||||
@@ -78,67 +77,57 @@ def add_invariant_check_fields(sles, filters):
|
||||
if balance_qty is None:
|
||||
balance_qty = sle.qty_after_transaction
|
||||
|
||||
sle.fifo_queue_qty = fifo_qty
|
||||
sle.fifo_stock_value = fifo_value
|
||||
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
|
||||
sle.balance_value_by_qty = (
|
||||
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
|
||||
)
|
||||
sle.expected_qty_after_transaction = balance_qty
|
||||
sle.stock_value_from_diff = balance_stock_value
|
||||
|
||||
# set difference fields
|
||||
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
|
||||
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
|
||||
sle.fifo_value_diff = sle.stock_value - fifo_value
|
||||
sle.fifo_valuation_diff = (
|
||||
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
|
||||
)
|
||||
sle.valuation_diff = (
|
||||
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
|
||||
)
|
||||
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
|
||||
|
||||
if maintains_fifo_queue(sle):
|
||||
add_fifo_fields(sle, sles[idx - 1] if idx else None)
|
||||
if not incorrect_idx and filters.get("show_incorrect_entries"):
|
||||
if is_sle_has_correct_data(sle, precision):
|
||||
continue
|
||||
else:
|
||||
incorrect_idx = idx
|
||||
|
||||
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
|
||||
incorrect_idx = idx
|
||||
if idx > 0:
|
||||
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
|
||||
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
|
||||
|
||||
if sle.batch_no:
|
||||
sle.use_batchwise_valuation = frappe.db.get_value(
|
||||
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
|
||||
)
|
||||
|
||||
if filters.get("show_incorrect_entries"):
|
||||
if incorrect_idx is None:
|
||||
return []
|
||||
return sles[max(incorrect_idx - 1, 0) :]
|
||||
if incorrect_idx > 0:
|
||||
sles = sles[cint(incorrect_idx) - 1 :]
|
||||
|
||||
return []
|
||||
|
||||
return sles
|
||||
|
||||
|
||||
def maintains_fifo_queue(sle):
|
||||
# no queue is maintained for serialized/batchwise-valued stock
|
||||
return not (
|
||||
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
|
||||
)
|
||||
def is_sle_has_correct_data(sle, precision):
|
||||
if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0:
|
||||
print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision))
|
||||
return False
|
||||
|
||||
|
||||
def add_fifo_fields(sle, prev_sle):
|
||||
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
|
||||
|
||||
fifo_qty = 0.0
|
||||
fifo_value = 0.0
|
||||
for qty, rate in queue:
|
||||
fifo_qty += qty
|
||||
fifo_value += qty * rate
|
||||
|
||||
sle.fifo_queue_qty = fifo_qty
|
||||
sle.fifo_stock_value = fifo_value
|
||||
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
|
||||
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
|
||||
sle.fifo_value_diff = sle.stock_value - fifo_value
|
||||
sle.fifo_valuation_diff = (
|
||||
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
|
||||
)
|
||||
# prev row may not maintain a queue; H and H - F stay blank across the gap
|
||||
if prev_sle and prev_sle.fifo_stock_value is not None:
|
||||
sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value
|
||||
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
|
||||
|
||||
|
||||
def is_sle_has_correct_data(sle, float_precision, currency_precision):
|
||||
return (
|
||||
flt(sle.difference_in_qty, float_precision) == 0.0
|
||||
and flt(sle.diff_value_diff, currency_precision) == 0.0
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_columns():
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
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_invariant_check.stock_ledger_invariant_check import execute
|
||||
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestStockLedgerInvariantCheck(FrappeTestCase):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_movements(self) -> str:
|
||||
# fresh item per test: db is only rolled back at class teardown on v15
|
||||
item = make_item(properties={"valuation_method": "FIFO"}).name
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01")
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02")
|
||||
make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03")
|
||||
return item
|
||||
|
||||
def test_diagnostic_rows_have_no_discrepancy(self):
|
||||
item = self.make_movements()
|
||||
|
||||
data = self.run_report(item_code=item)
|
||||
|
||||
self.assertEqual(len(data), 3)
|
||||
for row in data:
|
||||
self.assertLess(abs(row.difference_in_qty), 0.01)
|
||||
self.assertLess(abs(row.fifo_qty_diff), 0.01)
|
||||
self.assertLess(abs(row.diff_value_diff), 0.01)
|
||||
|
||||
def test_running_balance_matches(self):
|
||||
item = self.make_movements()
|
||||
|
||||
data = self.run_report(item_code=item)
|
||||
|
||||
self.assertEqual(data[-1].qty_after_transaction, 11)
|
||||
|
||||
def test_show_incorrect_entries(self):
|
||||
item = self.make_movements()
|
||||
|
||||
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
|
||||
|
||||
sle = frappe.get_last_doc(
|
||||
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5
|
||||
)
|
||||
|
||||
data = self.run_report(item_code=item, show_incorrect_entries=1)
|
||||
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
|
||||
self.assertEqual(data[-1].name, sle.name)
|
||||
|
||||
def test_batch_item_skips_fifo_queue_checks(self):
|
||||
item = make_item(
|
||||
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"}
|
||||
).name
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
|
||||
|
||||
data = self.run_report(item_code=item)
|
||||
self.assertTrue(data)
|
||||
for row in data:
|
||||
self.assertIsNone(row.fifo_qty_diff)
|
||||
self.assertIsNone(row.fifo_value_diff)
|
||||
|
||||
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
|
||||
@@ -205,10 +205,7 @@ def get_data(filters=None):
|
||||
|
||||
data = []
|
||||
if item_warehouse_map:
|
||||
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
|
||||
currency_precision = (
|
||||
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
|
||||
)
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
|
||||
|
||||
for item_warehouse in item_warehouse_map:
|
||||
report_data = stock_ledger_invariant_check(item_warehouse)
|
||||
@@ -218,11 +215,7 @@ def get_data(filters=None):
|
||||
|
||||
for row in report_data:
|
||||
if has_difference(
|
||||
row,
|
||||
float_precision,
|
||||
currency_precision,
|
||||
filters.difference_in,
|
||||
item_warehouse.valuation_method or valuation_method,
|
||||
row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method
|
||||
):
|
||||
row.update(
|
||||
{
|
||||
@@ -268,26 +261,23 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def has_difference(row, float_precision, currency_precision, difference_in, valuation_method):
|
||||
def has_difference(row, precision, difference_in, valuation_method):
|
||||
if valuation_method == "Moving Average":
|
||||
qty_diff = flt(row.difference_in_qty, float_precision)
|
||||
value_diff = flt(row.diff_value_diff, currency_precision)
|
||||
valuation_diff = flt(row.valuation_diff, currency_precision)
|
||||
qty_diff = flt(row.difference_in_qty, precision)
|
||||
value_diff = flt(row.diff_value_diff, precision)
|
||||
valuation_diff = flt(row.valuation_diff, precision)
|
||||
else:
|
||||
qty_diff = flt(row.difference_in_qty, float_precision)
|
||||
value_diff = flt(row.diff_value_diff, currency_precision)
|
||||
qty_diff = flt(row.difference_in_qty, precision)
|
||||
value_diff = flt(row.diff_value_diff, precision)
|
||||
|
||||
if row.stock_queue and json.loads(row.stock_queue):
|
||||
value_diff = value_diff or (
|
||||
flt(row.fifo_value_diff, currency_precision)
|
||||
or flt(row.fifo_difference_diff, currency_precision)
|
||||
flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
|
||||
)
|
||||
|
||||
qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision)
|
||||
qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
|
||||
|
||||
valuation_diff = flt(row.valuation_diff, currency_precision) or flt(
|
||||
row.fifo_valuation_diff, currency_precision
|
||||
)
|
||||
valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
|
||||
|
||||
if difference_in == "Qty" and qty_diff:
|
||||
return True
|
||||
@@ -297,5 +287,3 @@ def has_difference(row, float_precision, currency_precision, difference_in, valu
|
||||
return True
|
||||
elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -870,16 +870,10 @@ class update_entries_after:
|
||||
if (
|
||||
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
|
||||
and sle.voucher_detail_no
|
||||
and sle.actual_qty < 0
|
||||
and is_internal_transfer(sle)
|
||||
):
|
||||
# Anchor both legs of an internal-transfer PR/PI to the DN/SI incoming_rate;
|
||||
# otherwise an inward SLE that inherits a stale PR.valuation_rate leaks the
|
||||
# gap to COGS via divisional_loss.
|
||||
rate = get_incoming_rate_for_inter_company_transfer(sle)
|
||||
if sle.actual_qty < 0:
|
||||
sle.outgoing_rate = rate
|
||||
elif rate:
|
||||
sle.incoming_rate = rate
|
||||
sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle)
|
||||
|
||||
dimensions = get_inventory_dimensions()
|
||||
has_dimensions = False
|
||||
@@ -1086,11 +1080,7 @@ class update_entries_after:
|
||||
self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else []
|
||||
|
||||
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount)
|
||||
# Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed
|
||||
# total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child
|
||||
# rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction
|
||||
# (and every downstream balance). sle.actual_qty is the frozen movement for this entry.
|
||||
self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision)
|
||||
self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision)
|
||||
if flt(self.wh_data.qty_after_transaction, self.flt_precision):
|
||||
self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt(
|
||||
self.wh_data.qty_after_transaction, self.flt_precision
|
||||
@@ -2436,16 +2426,7 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float:
|
||||
if lcv_amount:
|
||||
lcv_rate = flt(lcv_amount / abs(sle.actual_qty))
|
||||
|
||||
charges_rate = 0.0
|
||||
if flt(sle.actual_qty) > 0:
|
||||
charge_fields = ["item_tax_amount", "rm_supp_cost"]
|
||||
charges = frappe.db.get_value(
|
||||
f"{sle.voucher_type} Item", sle.voucher_detail_no, charge_fields, as_dict=True
|
||||
)
|
||||
if charges:
|
||||
charges_rate = flt(sum(flt(charges.get(f)) for f in charge_fields)) / abs(sle.actual_qty)
|
||||
|
||||
return rate + lcv_rate + charges_rate
|
||||
return rate + lcv_rate
|
||||
|
||||
|
||||
def is_internal_transfer(sle):
|
||||
|
||||
@@ -35,52 +35,6 @@ class TestGetItemDetail(FrappeTestCase):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("price_list_rate"), 100)
|
||||
|
||||
def test_fetch_asset_category_expense_account_on_purchase_receipt(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
asset_category = "Test Expense Account Asset Category"
|
||||
if not frappe.db.exists("Asset Category", asset_category):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Asset Category",
|
||||
"asset_category_name": asset_category,
|
||||
"enable_cwip_accounting": 0,
|
||||
"depreciation_method": "Straight Line",
|
||||
"total_number_of_depreciations": 12,
|
||||
"frequency_of_depreciation": 1,
|
||||
"accounts": [
|
||||
{
|
||||
"company_name": "_Test Company",
|
||||
"fixed_asset_account": "_Test Fixed Asset - _TC",
|
||||
"accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC",
|
||||
"depreciation_expense_account": "_Test Depreciations - _TC",
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
asset_item = make_item(
|
||||
"Test Expense Account Asset Item",
|
||||
{"is_stock_item": 0, "is_fixed_asset": 1, "asset_category": asset_category},
|
||||
).item_code
|
||||
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": asset_item,
|
||||
"company": "_Test Company",
|
||||
"conversion_rate": 1.0,
|
||||
"price_list_currency": "USD",
|
||||
"plc_conversion_rate": 1.0,
|
||||
"doctype": "Purchase Receipt",
|
||||
"supplier": "_Test Supplier",
|
||||
"price_list": "_Test Buying Price List",
|
||||
"ignore_pricing_rule": 1,
|
||||
"qty": 1,
|
||||
}
|
||||
)
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("expense_account"), "_Test Fixed Asset - _TC")
|
||||
|
||||
# making this test in get_item_details test file as feat/fix is present in that method
|
||||
def test_fetch_price_from_list_rate_on_doc_save(self):
|
||||
# create item
|
||||
|
||||
@@ -232,7 +232,7 @@ class ServiceLevelAgreement(Document):
|
||||
if self.document_type == "Issue":
|
||||
return
|
||||
|
||||
service_level_agreement_fields = get_service_level_agreement_fields(self.document_type)
|
||||
service_level_agreement_fields = get_service_level_agreement_fields()
|
||||
meta = frappe.get_meta(self.document_type, cached=False)
|
||||
|
||||
if meta.custom:
|
||||
@@ -276,7 +276,6 @@ class ServiceLevelAgreement(Document):
|
||||
"hidden": field.get("hidden"),
|
||||
"description": field.get("description"),
|
||||
"default": field.get("default"),
|
||||
"link_filters": field.get("link_filters"),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
@@ -303,7 +302,6 @@ class ServiceLevelAgreement(Document):
|
||||
"hidden": field.get("hidden"),
|
||||
"description": field.get("description"),
|
||||
"default": field.get("default"),
|
||||
"link_filters": field.get("link_filters"),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
@@ -311,7 +309,7 @@ class ServiceLevelAgreement(Document):
|
||||
self.reset_field_properties(existing_field, "Custom Field", field)
|
||||
|
||||
def reset_field_properties(self, field, field_dt, sla_field):
|
||||
field = frappe.get_doc(field_dt, field.name)
|
||||
field = frappe.get_doc(field_dt, {"fieldname": field.fieldname})
|
||||
field.label = sla_field.get("label")
|
||||
field.fieldname = sla_field.get("fieldname")
|
||||
field.fieldtype = sla_field.get("fieldtype")
|
||||
@@ -322,7 +320,6 @@ class ServiceLevelAgreement(Document):
|
||||
field.hidden = sla_field.get("hidden")
|
||||
field.description = sla_field.get("description")
|
||||
field.default = sla_field.get("default")
|
||||
field.link_filters = sla_field.get("link_filters")
|
||||
field.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@@ -912,7 +909,7 @@ def record_assigned_users_on_failure(doc):
|
||||
doc.add_comment(comment_type="Assigned", text=message)
|
||||
|
||||
|
||||
def get_service_level_agreement_fields(doctype: str):
|
||||
def get_service_level_agreement_fields():
|
||||
return [
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -925,9 +922,6 @@ def get_service_level_agreement_fields(doctype: str):
|
||||
"fieldtype": "Link",
|
||||
"label": "Service Level Agreement",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", doctype]], indent=None
|
||||
),
|
||||
},
|
||||
{"fieldname": "priority", "fieldtype": "Link", "label": "Priority", "options": "Issue Priority"},
|
||||
{"fieldname": "response_by", "fieldtype": "Datetime", "label": "Response By", "read_only": 1},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# See license.txt
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
@@ -177,14 +176,11 @@ class TestServiceLevelAgreement(unittest.TestCase):
|
||||
self.assertEqual(lead_sla.name, default_sla.name)
|
||||
|
||||
# check SLA custom fields created for leads
|
||||
sla_fields = get_service_level_agreement_fields(doctype)
|
||||
sla_fields = get_service_level_agreement_fields()
|
||||
|
||||
for field in sla_fields:
|
||||
filters = {"dt": doctype, "fieldname": field.get("fieldname")}
|
||||
self.assertTrue(frappe.db.exists("Custom Field", filters))
|
||||
self.assertEqual(
|
||||
get_link_filters("Custom Field", filters),
|
||||
json.loads(field["link_filters"]) if field.get("link_filters") else None,
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": field.get("fieldname")})
|
||||
)
|
||||
|
||||
def test_docfield_creation_for_sla_on_custom_dt(self):
|
||||
@@ -204,66 +200,13 @@ class TestServiceLevelAgreement(unittest.TestCase):
|
||||
self.assertEqual(sla.name, default_sla.name)
|
||||
|
||||
# check SLA docfields created
|
||||
sla_fields = get_service_level_agreement_fields(doctype.name)
|
||||
sla_fields = get_service_level_agreement_fields()
|
||||
|
||||
for field in sla_fields:
|
||||
filters = {"fieldname": field.get("fieldname"), "parent": doctype.name}
|
||||
self.assertTrue(frappe.db.exists("DocField", filters))
|
||||
self.assertEqual(
|
||||
get_link_filters("DocField", filters),
|
||||
json.loads(field["link_filters"]) if field.get("link_filters") else None,
|
||||
self.assertTrue(
|
||||
frappe.db.exists("DocField", {"fieldname": field.get("fieldname"), "parent": doctype.name})
|
||||
)
|
||||
|
||||
def test_reset_field_properties_does_not_clobber_other_doctypes_field(self):
|
||||
"""Two doctypes each get their own "service_level_agreement" custom field
|
||||
(same fieldname, different owning doctype). Updating the field on one of
|
||||
them must not clobber the other's, even though both share the fieldname
|
||||
(regression test for the fix in reset_field_properties, see PR #56954)."""
|
||||
doctype_a = create_custom_doctype("Test SLA Dt A")
|
||||
doctype_b = create_custom_doctype("Test SLA Dt B")
|
||||
|
||||
for doctype in (doctype_a.name, doctype_b.name):
|
||||
create_service_level_agreement(
|
||||
default_service_level_agreement=1,
|
||||
holiday_list="__Test Holiday List",
|
||||
entity_type=None,
|
||||
entity=None,
|
||||
response_time=14400,
|
||||
resolution_time=21600,
|
||||
doctype=doctype,
|
||||
)
|
||||
|
||||
def get_sla_field_link_filters(doctype):
|
||||
return get_link_filters("DocField", {"parent": doctype, "fieldname": "service_level_agreement"})
|
||||
|
||||
self.assertEqual(
|
||||
get_sla_field_link_filters(doctype_a.name),
|
||||
[["Service Level Agreement", "document_type", "=", doctype_a.name]],
|
||||
)
|
||||
|
||||
# The field on doctype_b already exists, so creating another, entity-specific
|
||||
# SLA for doctype_b takes the "update existing field" branch (reset_field_properties)
|
||||
# instead of creating a new field.
|
||||
customer = create_customer()
|
||||
create_service_level_agreement(
|
||||
default_service_level_agreement=0,
|
||||
holiday_list="__Test Holiday List",
|
||||
entity_type="Customer",
|
||||
entity=customer,
|
||||
response_time=7200,
|
||||
resolution_time=10800,
|
||||
doctype=doctype_b.name,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
get_sla_field_link_filters(doctype_a.name),
|
||||
[["Service Level Agreement", "document_type", "=", doctype_a.name]],
|
||||
)
|
||||
self.assertEqual(
|
||||
get_sla_field_link_filters(doctype_b.name),
|
||||
[["Service Level Agreement", "document_type", "=", doctype_b.name]],
|
||||
)
|
||||
|
||||
def test_sla_application(self):
|
||||
# Default Service Level Agreement
|
||||
doctype = "Lead"
|
||||
@@ -419,11 +362,6 @@ class TestServiceLevelAgreement(unittest.TestCase):
|
||||
frappe.delete_doc("Service Level Agreement", d.name, force=1)
|
||||
|
||||
|
||||
def get_link_filters(field_doctype, filters):
|
||||
value = frappe.db.get_value(field_doctype, filters, "link_filters")
|
||||
return json.loads(value) if value else None
|
||||
|
||||
|
||||
def get_service_level_agreement(
|
||||
default_service_level_agreement=None, entity_type=None, entity=None, doctype="Issue"
|
||||
):
|
||||
@@ -664,8 +602,8 @@ def make_holiday_list():
|
||||
).insert()
|
||||
|
||||
|
||||
def create_custom_doctype(name="Test SLA on Custom Dt"):
|
||||
if not frappe.db.exists("DocType", name):
|
||||
def create_custom_doctype():
|
||||
if not frappe.db.exists("DocType", "Test SLA on Custom Dt"):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "DocType",
|
||||
@@ -688,13 +626,13 @@ def create_custom_doctype(name="Test SLA on Custom Dt"):
|
||||
},
|
||||
],
|
||||
"permissions": [{"role": "System Manager", "read": 1, "write": 1}],
|
||||
"name": name,
|
||||
"name": "Test SLA on Custom Dt",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
return doc
|
||||
else:
|
||||
return frappe.get_doc("DocType", name)
|
||||
return frappe.get_doc("DocType", "Test SLA on Custom Dt")
|
||||
|
||||
|
||||
def make_lead(creation=None, index=0, company=None):
|
||||
|
||||
Reference in New Issue
Block a user