Merge pull request #59072 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Diptanil Saha
2026-09-16 00:03:25 +05:30
committed by GitHub
47 changed files with 1533 additions and 201 deletions

View File

@@ -17,7 +17,8 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import getdate
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext.controllers.accounts_controller import AccountsController
@@ -140,6 +141,31 @@ class Dunning(AccountsController):
)
row.dunning_level = len(past_dunnings) + 1
def get_unpaid_base_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in company currency."""
if not self.base_dunning_amount:
return 0.0
return flt(
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
self.precision("base_dunning_amount"),
)
def get_unpaid_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
return flt(
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
self.precision("dunning_amount"),
)
def get_unpaid_overdue_payments(self):
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
return [
(row, outstanding)
for row in self.overdue_payments
if (outstanding := get_current_outstanding(row)) > 0
]
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = [
@@ -154,6 +180,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -252,11 +279,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
if has_outstanding:
break
new_status = "Resolved" if not has_outstanding else "Unresolved"
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
if dunning.status != new_status:
dunning.status = new_status
dunning.save()
def update_dunnings_linked_to_payment(payment_entry):
"""Refresh dunnings whose interest and fee are settled by this payment."""
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
for name in dunnings:
dunning = frappe.get_doc("Dunning", name)
if dunning.docstatus != 1:
continue
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
# resolving by hand waives the interest, only an invoice that is owed again reopens it
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
return
if dunning.status != new_status:
dunning.db_set("status", new_status, notify=True)
def get_paid_dunning_amount(dunning: str) -> float:
"""Interest and fee collected for this dunning, in company currency."""
deduction = frappe.qb.DocType("Payment Entry Deduction")
payment_entry = frappe.qb.DocType("Payment Entry")
paid = (
frappe.qb.from_(deduction)
.join(payment_entry)
.on(payment_entry.name == deduction.parent)
.select(Sum(deduction.amount))
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
).run()
# the dunning amount is booked as a negative deduction, against the income account
return -flt(paid[0][0]) if paid else 0.0
def get_current_outstanding(overdue_payment) -> float:
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
invoice = frappe.db.get_value(
"Sales Invoice",
overdue_payment.sales_invoice,
["outstanding_amount", "currency", "party_account_currency"],
as_dict=True,
)
schedule_outstanding = (
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
if overdue_payment.payment_schedule
else flt(overdue_payment.outstanding)
)
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
return 0.0
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
if invoice.currency == invoice.party_account_currency:
outstanding = min(outstanding, flt(invoice.outstanding_amount))
return outstanding
def get_linked_dunnings_as_per_state(sales_invoice, state):

View File

@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
create_sales_invoice,
create_sales_invoice_against_cost_center,
)
@@ -71,6 +72,123 @@ class TestDunning(FrappeTestCase):
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
"""
Regression for #58220: paying the invoice without the interest and fee must not
resolve the dunning, the interest is still owed and has to stay claimable.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "4", nowdate()
pe.insert()
pe.submit()
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the interest and fee can still be collected on their own
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "5", nowdate()
self.assertEqual(pe.references, [])
self.assertEqual(round(pe.paid_amount, 2), 10.41)
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
# cancelling the interest payment makes the dunning claimable again
pe.cancel()
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
"""
The payment collecting the interest links back to the dunning, which must not stand in
the way of cancelling it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "6", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
dunning.cancel()
self.assertEqual(dunning.docstatus, 2)
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
"""
Resolving a dunning by hand waives its interest, so a later payment of the invoice
must not reopen it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
# what the "Resolve" button does
dunning.reload()
dunning.status = "Resolved"
dunning.save()
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "7", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
"""
The interest and fee are collected as a Payment Entry deduction, a company currency
field, so what is left to collect has to be measured in the same currency.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -15),
customer="_Test Customer USD",
currency="USD",
conversion_rate=50,
rate=100,
debit_to="_Test Receivable USD - _TC",
)
dunning = create_dunning_from_sales_invoice(si.name)
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
dunning.dunning_type = dunning_type.name
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.income_account = dunning_type.income_account
dunning.cost_center = dunning_type.cost_center
dunning.save()
self.assertEqual(dunning.currency, "USD")
self.assertEqual(dunning.conversion_rate, 50)
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
# nothing collected yet, in either currency
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the deduction booking the interest is in company currency
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
def test_fetch_overdue_payments(self):
"""
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.

View File

@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
],
primary_action: function () {
var data = d.get_values();
data.company = frm.doc.company;
frappe.call({
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",

View File

@@ -168,7 +168,7 @@
}
],
"is_submittable": 1,
"modified": "2019-05-30 19:08:21.199759",
"modified": "2026-09-09 17:04:59.512294",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoice Discounting",
@@ -185,7 +185,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
@@ -194,4 +194,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters):
filters = frappe._dict(json.loads(filters))
if not filters.get("company"):
frappe.throw(_("Please set company on the Document before requesting for invoices."))
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
frappe.has_permission("Invoice Discounting", throw=True)
cond = []
if filters.customer:
cond.append("customer=%(customer)s")

View File

@@ -3,6 +3,142 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates import (
execute as backfill_not_applicable,
)
class TestItemTaxTemplate(unittest.TestCase):
pass
class TestGermanNotApplicableBackfill(FrappeTestCase):
"""Run the `not_applicable` backfill patch against a seeded German company.
The company is created from the shipped German defaults, so the templates the
patch has to recognise are the ones a real site got. Each test resets the flag
to its pre-patch state (`not_applicable = 0`) and runs the patch.
"""
TITLES = ("19 %", "7 %", "0%")
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = "_Test German Item Tax Templates"
if not frappe.db.exists("Company", cls.company):
frappe.get_doc(
{
"doctype": "Company",
"company_name": cls.company,
"abbr": "_TGITT",
"country": "Germany",
"default_currency": "EUR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
).insert()
cls.templates = {
title: frappe.db.get_value("Item Tax Template", {"company": cls.company, "title": title}, "name")
for title in cls.TITLES
}
assert all(cls.templates.values()), f"German defaults not seeded: {cls.templates}"
def setUp(self):
frappe.db.savepoint("before_backfill_test")
self.addCleanup(frappe.db.rollback, save_point="before_backfill_test")
self.seeded_flags = self.flagged_rows()
# every default template ships not-applicable rows, otherwise the patch
# would be tested against effectively empty data
for title in self.TITLES:
self.assertTrue(self.seeded_flags[title], f"no not-applicable rows seeded in {title}")
def flagged_rows(self, title=None) -> dict[str, set]:
"""Detail rows currently marked as not applicable, per template title."""
return {
t: {
d.name
for d in frappe.get_all(
"Item Tax Template Detail",
filters={"parent": name, "not_applicable": 1},
fields=["name"],
)
}
for t, name in self.templates.items()
if title in (None, t)
}
def clear_flags(self):
"""Restore the pre-patch state: zero rate, no flag."""
for name in self.templates.values():
frappe.db.set_value(
"Item Tax Template Detail",
{"parent": name},
"not_applicable",
0,
update_modified=False,
)
self.assertEqual(self.flagged_rows(), {t: set() for t in self.TITLES})
def add_zero_rate_row(self, title, account_name, account_number):
"""Add a user-defined zero-rate row, as a customised site would have."""
like_account = frappe.db.get_value(
"Account", {"company": self.company, "account_name": "Umsatzsteuer 19 %"}, "name"
)
account = frappe.get_doc(
{
"doctype": "Account",
"company": self.company,
"account_name": account_name,
"account_number": account_number,
"account_type": "Tax",
"parent_account": frappe.db.get_value("Account", like_account, "parent_account"),
}
).insert()
template = frappe.get_doc("Item Tax Template", self.templates[title])
template.append("taxes", {"tax_type": account.name, "tax_rate": 0})
template.save()
def test_backfills_unmodified_defaults(self):
self.clear_flags()
backfill_not_applicable()
self.assertEqual(self.flagged_rows(), self.seeded_flags)
def test_keeps_customised_template_untouched(self):
self.clear_flags()
self.add_zero_rate_row("19 %", "Sonstige Umsatzsteuer", "9998")
backfill_not_applicable()
self.assertEqual(self.flagged_rows("19 %"), {"19 %": set()})
self.assertEqual(self.flagged_rows("7 %"), {"7 %": self.seeded_flags["7 %"]})
def test_keeps_duplicate_account_name_untouched(self):
"""A numbered account can share `account_name` with a default one.
Its identifier collapses onto the default's, so only the row count tells
the customised template apart from an untouched one.
"""
self.clear_flags()
self.add_zero_rate_row("7 %", "Umsatzsteuer 19 %", "9999")
backfill_not_applicable()
self.assertEqual(self.flagged_rows("7 %"), {"7 %": set()})
self.assertEqual(self.flagged_rows("19 %"), {"19 %": self.seeded_flags["19 %"]})
def test_rerun_changes_nothing(self):
def snapshot():
return frappe.get_all(
"Item Tax Template Detail",
filters={"parent": ("in", tuple(self.templates.values()))},
fields=["name", "not_applicable", "tax_rate", "modified"],
order_by="name",
)
before = snapshot()
backfill_not_applicable()
self.assertEqual(snapshot(), before)

View File

@@ -3,6 +3,7 @@
import json
from datetime import date
from functools import reduce
import frappe
@@ -122,8 +123,14 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
def update_linked_dunnings(self):
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
update_dunnings_linked_to_payment(self)
def validate_for_repost(self):
validate_docs_for_voucher_types(["Payment Entry"])
validate_docs_for_deferred_accounting([self.name], [])
@@ -225,6 +232,7 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.delink_advance_entry_references()
self.set_status()
@@ -2891,15 +2899,15 @@ def get_reference_details(
@frappe.whitelist()
def get_payment_entry(
dt,
dn,
party_amount=None,
bank_account=None,
bank_amount=None,
party_type=None,
payment_type=None,
reference_date=None,
created_from_payment_request=False,
dt: str,
dn: str,
party_amount: int | float | None = None,
bank_account: str | None = None,
bank_amount: int | float | None = None,
party_type: str | None = None,
payment_type: str | None = None,
reference_date: str | date | None = None,
created_from_payment_request: bool | None = False,
):
frappe.has_permission("Payment Entry", ptype="create", throw=True)
@@ -3000,7 +3008,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
pe.append(
"references",
{
@@ -3008,21 +3016,23 @@ def get_payment_entry(
"reference_name": overdue_payment.sales_invoice,
"payment_term": overdue_payment.payment_term,
"due_date": overdue_payment.due_date,
"total_amount": overdue_payment.outstanding,
"outstanding_amount": overdue_payment.outstanding,
"allocated_amount": overdue_payment.outstanding,
"total_amount": outstanding,
"outstanding_amount": outstanding,
"allocated_amount": outstanding,
},
)
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * doc.dunning_amount,
"description": _("Interest and/or dunning fee"),
},
)
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * unpaid_dunning_amount,
"description": _("Interest and/or dunning fee"),
"dunning": doc.name,
},
)
else:
pe.append(
"references",
@@ -3304,8 +3314,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
# only what is left to collect, the totals on the dunning are the ones it was raised with
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
grand_total += doc.get_unpaid_dunning_amount()
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))

View File

@@ -10,7 +10,8 @@
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
"description",
"dunning"
],
"fields": [
{
@@ -55,12 +56,21 @@
"fieldtype": "Check",
"label": "Is Exchange Gain / Loss?",
"read_only": 1
},
{
"fieldname": "dunning",
"fieldtype": "Link",
"label": "Dunning",
"no_copy": 1,
"options": "Dunning",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 14:26:11.312950",
"modified": "2026-08-17 11:20:35.482913",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",

View File

@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
dunning: DF.Link | None
is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
const me = this;
super.refresh();
hide_fields(this.frm.doc);
hide_fields(this.frm);
// Show / Hide button
this.show_general_ledger();
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
@@ -435,7 +435,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
is_paid() {
hide_fields(this.frm.doc);
hide_fields(this.frm);
if (cint(this.frm.doc.is_paid)) {
this.frm.set_value("allocate_advances_automatically", 0);
this.frm.set_value("payment_terms_template", "");
@@ -499,28 +499,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
// Hide Fields
// ------------
function hide_fields(doc) {
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
function hide_fields(frm) {
const doc = frm.doc;
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
if (cint(doc.is_paid) == 1) {
hide_field(parent_fields);
frm.toggle_display(parent_fields, false);
} else {
for (var i in parent_fields) {
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
if (!docfield.hidden) unhide_field(parent_fields[i]);
for (const fieldname of parent_fields) {
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
if (!docfield.hidden) frm.toggle_display(fieldname, true);
}
}
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
if (cur_frm.fields_dict["items"]) {
cur_frm.fields_dict["items"].grid.set_column_disp(
item_fields_stock,
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
);
if (frm.fields_dict["items"]) {
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
}
cur_frm.refresh_fields();
frm.refresh_fields();
}
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
@@ -736,7 +734,7 @@ frappe.ui.form.on("Purchase Invoice", {
},
update_stock: function (frm) {
hide_fields(frm.doc);
hide_fields(frm);
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
},

View File

@@ -3081,6 +3081,23 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_partial_returns_ignore_received_qty_without_update_stock(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
invoice = make_purchase_invoice(qty=10, received_qty=10)
first_return = make_return_doc(invoice.doctype, invoice.name)
first_return.items[0].qty = -4
first_return.save().submit()
self.assertEqual(first_return.items[0].received_qty, -10)
second_return = make_return_doc(invoice.doctype, invoice.name)
second_return.items[0].qty = -6
second_return.save().submit()
self.assertEqual(second_return.docstatus, 1)
def test_apply_discount_on_grand_total(self):
"""
To test if after applying discount on grand total,

View File

@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
def execute_repost_payment_ledger(docname):
"""Repost Payment Ledger Entries by background job."""
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
job_name = "payment_ledger_repost_" + docname
frappe.enqueue(

View File

@@ -253,6 +253,8 @@ class AccountsController(TransactionBase):
if self.get("_action") and self._action != "update_after_submit":
self.set_missing_values(for_validate=True)
self.validate_price_list()
if self.get("_action") == "submit":
self.remove_bundle_for_non_stock_invoices()
@@ -384,6 +386,28 @@ class AccountsController(TransactionBase):
def is_drop_ship(items):
return any(item.delivered_by_supplier for item in items)
def validate_price_list(self):
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
price_list = self.get(price_list_field)
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
return
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
if (
self.get("is_return")
and self.get("return_against")
and price_list
== frappe.db.get_value(
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
)
):
return
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
def set_default_letter_head(self):
if hasattr(self, "letter_head") and not self.letter_head:
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")

View File

@@ -190,7 +190,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
fields = ["qty"]
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]:
tracks_accepted_rejected_split = doc.doctype in (
"Purchase Receipt",
"Subcontracting Receipt",
) or (doc.doctype == "Purchase Invoice" and doc.update_stock)
if tracks_accepted_rejected_split:
if not args.get("return_qty_from_rejected_warehouse"):
fields.extend(["received_qty", "rejected_qty"])
else:

View File

@@ -1533,7 +1533,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
@frappe.whitelist()
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company)
filters = frappe._dict(company=company, valuation_field_type="Currency")
doc = frappe.get_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_sl_preview")
@@ -1574,7 +1574,7 @@ def get_accounting_ledger_preview(doc, filters):
columns = get_gl_columns(filters)
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
gl_columns = get_columns(columns, fields)
gl_columns = get_columns(columns, fields, erpnext.get_company_currency(filters.company))
gl_data = get_data(fields, gl_entries)
return gl_columns, gl_data
@@ -1616,7 +1616,7 @@ def get_stock_ledger_preview(doc, filters):
columns = get_sl_columns(filters)
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
sl_columns = get_columns(columns, columns_fields)
sl_columns = get_columns(columns, columns_fields, erpnext.get_company_currency(filters.company))
sl_data = get_data(columns_fields, sl_entries)
return sl_columns, sl_data
@@ -1635,7 +1635,8 @@ def get_sl_entries_for_preview(doctype, docname, fields):
entry["out_qty"] = abs(entry.actual_qty)
entry["in_qty"] = 0
entry["in_out_rate"] = entry["valuation_rate"]
if entry.actual_qty < 0:
entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty
return sl_entries
@@ -1644,12 +1645,23 @@ def get_gl_entries_for_preview(doctype, docname, fields):
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
def get_columns(raw_columns, fields):
return [
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
for d in raw_columns
if not d.get("hidden") and d.get("fieldname") in fields
]
def get_columns(raw_columns, fields, currency):
columns = []
for source_column in raw_columns:
if source_column.get("hidden") or source_column.get("fieldname") not in fields:
continue
column = {
"name": source_column.get("label"),
"editable": False,
"width": 110,
"fieldtype": source_column.get("fieldtype"),
}
if column["fieldtype"] == "Currency":
column["options"] = currency
columns.append(column)
return columns
def get_data(raw_columns, raw_data):

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest import TestCase
from unittest.mock import patch
import frappe
from erpnext.controllers.stock_controller import get_sl_entries_for_preview
class TestLedgerPreview(TestCase):
def test_in_out_rate_is_only_set_for_outgoing_entries(self):
stock_ledger_entries = [
frappe._dict(actual_qty=5, stock_value_difference=10),
frappe._dict(actual_qty=-5, stock_value_difference=-15),
]
with patch("frappe.get_all", return_value=stock_ledger_entries):
entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", [])
self.assertIsNone(entries[0].get("in_out_rate"))
self.assertEqual(entries[1].in_out_rate, 3)

View File

@@ -53,7 +53,7 @@ def set_booking_setting(field, value):
def slot_on(days_from_now, hour, minute=0):
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
day = getdate() + datetime.timedelta(days=days_from_now)
return datetime.datetime.combine(day, datetime.time(hour, minute))
@@ -136,7 +136,7 @@ class TestAppointment(FrappeTestCase):
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
appointment = create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
date=str(getdate() + datetime.timedelta(days=days_from_now)),
time=time,
tz=get_system_timezone(),
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
@@ -280,7 +280,7 @@ class TestAppointment(FrappeTestCase):
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=3)),
date=str(getdate() + datetime.timedelta(days=3)),
time="10:00:00",
tz="UTC",
contact={
@@ -295,7 +295,7 @@ class TestAppointment(FrappeTestCase):
def test_booked_slot_unavailable_on_portal(self):
self._configure_booking_settings()
tz = get_system_timezone()
day = datetime.date.today() + datetime.timedelta(days=2)
day = getdate() + datetime.timedelta(days=2)
def get_availability():
with self.set_user("Guest"):

View File

@@ -10,7 +10,7 @@ import frappe
from frappe import _, bold
from frappe.core.doctype.version.version import get_diff
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, cstr, flt, today
from frappe.utils import cint, cstr, flt, get_link_to_form, today
from frappe.website.website_generator import WebsiteGenerator
import erpnext
@@ -653,6 +653,19 @@ class BOM(WebsiteGenerator):
frappe.throw(_("Quantity required for Item {0} in row {1}").format(m.item_code, m.idx))
check_list.append(m)
bom_items = {self.item}
bom_items.update(d.item_code for d in self.get("items"))
bom_items.update(d.item_code for d in self.get("scrap_items"))
if disabled_items := frappe.db.get_all(
"Item", filters={"item_code": ("in", list(bom_items)), "disabled": 1}, pluck="name"
):
frappe.throw(
_("Disabled Item {0} cannot be used in BOMs.").format(
", ".join(get_link_to_form("Item", item) for item in disabled_items)
)
)
def check_recursion(self, bom_list=None):
"""Check whether recursion occurs in any bom"""

View File

@@ -1182,7 +1182,7 @@ def make_material_request(source_name, target_doc=None):
@frappe.whitelist()
def make_stock_entry(source_name, target_doc=None):
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -1194,6 +1194,9 @@ def make_stock_entry(source_name, target_doc=None):
target.qty = pending_rm_qty
def set_missing_values(source, target):
if not source.items:
frappe.throw(_("This Job Card has no raw materials to transfer."))
target.purpose = "Material Transfer for Manufacture"
target.from_bom = 1

View File

@@ -4,20 +4,66 @@ import frappe
from frappe.test_runner import make_test_records
from frappe.tests.utils import FrappeTestCase
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom
from erpnext.manufacturing.doctype.workstation.workstation import (
NotInWorkingHoursError,
WorkstationHolidayError,
check_if_within_operating_hours,
get_raw_materials,
)
test_dependencies = ["Warehouse"]
test_dependencies = ["Warehouse", "Item"]
test_records = frappe.get_test_records("Workstation")
make_test_records("Workstation")
class TestWorkstation(FrappeTestCase):
def test_get_raw_materials_without_items(self):
job_card = frappe.get_doc(
{
"doctype": "Job Card",
"company": "_Test Company",
"wip_warehouse": "_Test Warehouse 1 - _TC",
}
).insert(ignore_mandatory=True)
self.assertEqual(get_raw_materials([job_card.name]), {})
with self.assertRaisesRegex(frappe.ValidationError, "This Job Card has no raw materials to transfer"):
make_stock_entry(job_card.name)
job_card.reload()
self.assertFalse(job_card.items)
self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name}))
def test_get_raw_materials_with_items(self):
job_card = frappe.get_doc(
{
"doctype": "Job Card",
"company": "_Test Company",
"wip_warehouse": "_Test Warehouse 1 - _TC",
"items": [
{
"item_code": "_Test Item",
"source_warehouse": "_Test Warehouse - _TC",
"required_qty": 5,
"transferred_qty": 2,
}
],
}
).insert(ignore_mandatory=True)
materials = get_raw_materials([job_card.name])
self.assertEqual(list(materials), [job_card.name])
self.assertEqual(len(materials[job_card.name]), 1)
material = materials[job_card.name][0]
self.assertEqual(material.item_code, "_Test Item")
self.assertEqual(material.required_qty, 5)
self.assertEqual(material.transferred_qty, 2)
self.assertEqual(material.source_warehouse, "_Test Warehouse - _TC")
def test_validate_timings(self):
check_if_within_operating_hours(
"_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00"

View File

@@ -449,4 +449,6 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates

View File

@@ -0,0 +1,32 @@
import frappe
def execute():
"""Recalculate billing status of Delivery Notes left open by a return.
Returning the uninvoiced qty of a Delivery Note did not recalculate the original
Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice.
"""
dn = frappe.qb.DocType("Delivery Note")
dn_item = frappe.qb.DocType("Delivery Note Item")
delivery_notes = (
frappe.qb.from_(dn)
.inner_join(dn_item)
.on(dn_item.parent == dn.name)
.select(dn.name)
.distinct()
.where(
(dn.docstatus == 1)
& (dn.is_return == 0)
& dn.status.isin(["To Bill", "Partially Billed"])
& (dn_item.returned_qty > 0)
)
.run(pluck=True)
)
for name in delivery_notes:
doc = frappe.get_doc("Delivery Note", name)
doc.update_billing_percentage(update_modified=False)
doc.load_from_db()
doc.set_status(update=True, update_modified=False)

View File

@@ -0,0 +1,226 @@
import frappe
# Snapshot of the relevant German defaults when this migration was written.
# Migration patches must not read mutable setup data, otherwise future edits to
# country_wise_tax.json would change what this patch does on sites that have not
# run it yet.
#
# For numbered charts, compare account_number + root_type because Account.account_name
# is not unique within a company.
SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
{
("3801", "Liability"),
("3802", "Liability"),
("3835", "Liability"),
("1401", "Asset"),
("1402", "Asset"),
("1541", "Asset"),
}
)
SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
{
("3806", "Liability"),
("3804", "Liability"),
("3837", "Liability"),
("1406", "Asset"),
("1404", "Asset"),
("1540", "Asset"),
}
)
SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
{
("1771", "Liability"),
("1772", "Liability"),
("1785", "Liability"),
("1571", "Asset"),
("1572", "Asset"),
("1541", "Asset"),
}
)
SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
{
("1776", "Liability"),
("1774", "Liability"),
("1787", "Liability"),
("1576", "Asset"),
("1574", "Asset"),
("1540", "Asset"),
}
)
STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS = frozenset(
{
("Umsatzsteuer 7 %", "Liability"),
("Umsatzsteuer aus innergemeinschaftlichem Erwerb", "Liability"),
("Umsatzsteuer nach § 13b UStG", "Liability"),
("Abziehbare Vorsteuer 7 %", "Asset"),
("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb", "Asset"),
("Abziehbare Vorsteuer nach § 13b UStG", "Asset"),
}
)
STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS = frozenset(
{
("Umsatzsteuer 19 %", "Liability"),
("Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %", "Liability"),
("Umsatzsteuer nach § 13b UStG 19 %", "Liability"),
("Abziehbare Vorsteuer 19 %", "Asset"),
("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %", "Asset"),
("Abziehbare Vorsteuer nach § 13b UStG 19 %", "Asset"),
}
)
STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
{
("2321", "Liability"),
("2331", "Liability"),
("2341", "Liability"),
("1521", "Asset"),
("1531", "Asset"),
("1541", "Asset"),
}
)
STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
{
("2320", "Liability"),
("2330", "Liability"),
("2340", "Liability"),
("1520", "Asset"),
("1530", "Asset"),
("1540", "Asset"),
}
)
GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS = {
"SKR03 mit Kontonummern": {
"identifier_field": "account_number",
"templates": {
"19 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
"7 %": SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
"0 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
| SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
| frozenset({("1588", "Asset")}),
},
},
"SKR04 mit Kontonummern": {
"identifier_field": "account_number",
"templates": {
"19 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
"7 %": SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
"0 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
| SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
| frozenset({("1433", "Asset")}),
},
},
"Standard": {
"identifier_field": "account_name",
"templates": {
"19 %": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS,
"7 %": STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS,
"0%": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS
| STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS
| frozenset({("Entstandene Einfuhrumsatzsteuer", "Asset")}),
},
},
"Standard with Numbers": {
"identifier_field": "account_number",
"templates": {
"19%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
"7%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
"0 %": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
| STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
| frozenset({("1550", "Asset")}),
},
},
}
def update_account_cache(accounts, account_cache):
missing_accounts = set(accounts) - set(account_cache)
if not missing_accounts:
return
for account in frappe.get_all(
"Account",
filters={"name": ("in", tuple(sorted(missing_accounts)))},
fields=["name", "account_name", "account_number", "root_type"],
):
account_cache[account.name] = account
def get_account_identifier(account, identifier_field, account_cache):
cached_account = account_cache.get(account)
if not cached_account:
return None
return cached_account.get(identifier_field), cached_account.root_type
def execute():
"""Backfill `not_applicable` on Item Tax Template Details for German companies.
Before the `not_applicable` flag existed, German default templates used
`tax_rate: 0` to mean "this tax does not apply to the item" (as opposed to
an explicit 0% rate). For each German company, this patch looks up the
historical defaults for its Chart of Accounts and sets
`not_applicable = 1` on detail rows that still match those defaults
(same template title, same zero-rate tax account identifier set, flag still unset),
leaving any user-customised rows untouched.
"""
companies = frappe.get_all(
"Company",
filters={"country": "Germany"},
fields=["name", "chart_of_accounts"],
)
account_cache = {}
for company in companies:
chart = GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS.get(company.chart_of_accounts)
if not chart:
continue
identifier_field = chart["identifier_field"]
for template_title, target_accounts in chart["templates"].items():
itt_names = frappe.get_all(
"Item Tax Template",
filters={"company": company.name, "title": template_title},
pluck="name",
)
for itt_name in itt_names:
zero_rate_details = frappe.get_all(
"Item Tax Template Detail",
filters={"parent": itt_name, "tax_rate": 0},
fields=["name", "tax_type", "not_applicable"],
)
update_account_cache((d.tax_type for d in zero_rate_details), account_cache)
zero_rate_accounts_by_detail = {
d.name: get_account_identifier(d.tax_type, identifier_field, account_cache)
for d in zero_rate_details
}
if any(identifier is None for identifier in zero_rate_accounts_by_detail.values()):
continue
# Compare the row count as well. Account names are only implicitly unique
# among number-less accounts (`Account.name` is `[number - ]account_name - abbr`),
# so on a mixed chart a numbered account can share `account_name` with a
# default one. Without this, such a user-added zero-rate row collapses onto a
# default identifier and makes a customised template look untouched.
if len(zero_rate_accounts_by_detail) != len(target_accounts):
continue
if set(zero_rate_accounts_by_detail.values()) != target_accounts:
continue
for d in zero_rate_details:
if not d.not_applicable:
frappe.db.set_value(
"Item Tax Template Detail",
d.name,
"not_applicable",
1,
update_modified=False,
)

View File

@@ -83,7 +83,7 @@ erpnext.accounts.ledger_preview = {
columns.forEach((col) => {
if (col.fieldtype === "Currency") {
col.format = (value) => {
return format_currency(value);
return format_currency(value, col.options);
};
}
});

View File

@@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
args = {
party: frm.doc.customer || frm.doc.party_name,
party_type: party_type,
price_list: frm.doc.selling_price_list,
price_list: frappe.defaults.get_default("selling_price_list"),
};
} else if (frm.doc.supplier) {
args = {
party: frm.doc.supplier,
party_type: "Supplier",
bill_date: frm.doc.bill_date,
price_list: frm.doc.buying_price_list,
price_list: frappe.defaults.get_default("buying_price_list"),
};
}

View File

@@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = {
width: 80,
},
],
onload: function (query_report) {
query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => {
build_1099_print(query_report);
});
},
};
function build_1099_print(query_report) {
let filters = JSON.stringify(query_report.get_values());
let w = window.open(
"/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?" +
"&filters=" +
encodeURIComponent(filters)
);
// w.print();
}

View File

@@ -84,46 +84,6 @@ def get_columns():
]
@frappe.whitelist()
def irs_1099_print(filters):
if not filters:
frappe._dict(
{
"company": frappe.db.get_default("Company"),
"fiscal_year": frappe.db.get_default("Fiscal Year"),
}
)
else:
filters = frappe._dict(json.loads(filters))
fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True)
fiscal_year = cstr(fiscal_year_doc.year_start_date.year)
company_address = get_payer_address_html(filters.company)
company_tin = frappe.db.get_value("Company", filters.company, "tax_id")
columns, data = execute(filters)
template = frappe.get_doc("Print Format", "IRS 1099 Form").html
output = PdfWriter()
for row in data:
row["fiscal_year"] = fiscal_year
row["company"] = filters.company
row["company_tin"] = company_tin
row["payer_street_address"] = company_address
row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html(
"Supplier", row.supplier
)
row["payments"] = fmt_money(row["payments"], precision=0, currency="USD")
get_pdf(render_template(template, row), output=output if output else None)
frappe.local.response.filename = (
f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}"
)
frappe.local.response.filecontent = read_multi_pdf(output)
frappe.local.response.type = "download"
def get_payer_address_html(company):
address_list = frappe.db.sql(
"""

View File

@@ -609,11 +609,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
# if the current user does not have permissions to override credit limit,
# prompt them to send out an email to the controller users
frappe.msgprint(
message,
title=_("Credit Limit Crossed"),
raise_exception=1,
primary_action={
primary_action = (
{
"label": "Send Email",
"server_action": "erpnext.selling.doctype.customer.customer.send_emails",
"hide_on_success": True,
@@ -623,7 +620,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
"credit_limit": credit_limit,
"credit_controller_users_list": credit_controller_users,
},
},
}
if frappe.has_permission("Customer", ptype="email", doc=customer)
else None
)
frappe.msgprint(
message,
title=_("Credit Limit Crossed"),
raise_exception=1,
primary_action=primary_action,
)
@@ -631,6 +637,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
def send_emails(args):
args = json.loads(args)
subject = _("Credit limit reached for customer {0}").format(args.get("customer"))
frappe.has_permission("Customer", ptype="email", doc=args.get("customer"), throw=True)
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(
args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit")
)

View File

@@ -14,7 +14,7 @@ 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 Case, Criterion
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, IfNull, Round, Sum
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html
from pypika import Order
@@ -1973,8 +1973,26 @@ def get_stock_reservation_status():
return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")
def get_pending_qty_criterion(sales_order_item):
"""Mirror the mapper's pending quantity check."""
invoice_item = qb.DocType("Sales Invoice Item")
billed_qty = (
qb.from_(invoice_item)
.select(IfNull(Sum(invoice_item.qty), 0))
.where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name))
)
qty_precision = frappe.get_precision("Sales Order Item", "qty")
has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0
has_unbilled_delivered_qty = (
Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0
) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0)
return has_unbilled_ordered_qty & has_unbilled_delivered_qty
def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
"""Return the amount check for UI candidates. The mapper checks pending quantity."""
"""Return the row level checks the Sales Invoice mapper applies."""
global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
allowance = (
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
@@ -1984,8 +2002,11 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item)
Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100)
)
is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
is_billable_row = (
(sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item)
)
return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
return is_unit_price_row | is_billable_row
def has_potentially_billable_items(sales_order: str) -> bool:

View File

@@ -290,6 +290,48 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertEqual(len(make_sales_invoice(so.name).items), 0)
def test_fully_billed_order_is_not_offered_within_billing_allowance(self):
item = make_item(
"_Test Fully Billed Allowance Item",
{"is_stock_item": 1, "over_billing_allowance": 0},
).name
so = make_sales_order(item_code=item, qty=10, rate=100)
si = make_sales_invoice(so.name)
si.insert()
si.submit()
so.load_from_db()
self.assertEqual(flt(so.per_billed), 100)
filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
self.assertFalse(has_potentially_billable_items(so.name))
rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters)
self.assertNotIn(so.name, [row.name for row in rows])
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
def test_order_with_sub_precision_pending_qty_is_not_offered(self):
item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name
so = make_sales_order(item_code=item, qty=10, rate=100)
si = make_sales_invoice(so.name)
si.get("items")[0].rate = 90
si.insert()
si.submit()
qty_precision = frappe.get_precision("Sales Order Item", "qty")
billed_qty = 10 - 10 ** -(qty_precision + 1)
frappe.db.set_value(
"Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False
)
self.assertFalse(has_potentially_billable_items(so.name))
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return

View File

@@ -68,12 +68,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True
account = warehouse.account
if not account and warehouse.parent_warehouse:
if warehouse_account:
if warehouse_account.get(warehouse.parent_warehouse):
account = warehouse_account.get(warehouse.parent_warehouse).account
else:
from frappe.utils.nestedset import rebuild_tree
rebuild_tree("Warehouse", "parent_warehouse")
if parent := warehouse_account.get(warehouse.parent_warehouse):
account = parent.account
else:
account = frappe.db.sql(
"""

View File

@@ -1099,6 +1099,67 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn.per_billed, 50)
self.assertEqual(dn.status, "Partially Billed")
def test_billing_status_repair_patch(self):
"""Returns submitted before #58869 left the original Delivery Note's per_billed stale.
The repair patch recalculates such notes: a directly invoiced one whose remaining
qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back
to To Bill.
"""
from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
# Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed
make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100)
dn = create_delivery_note(qty=5)
si = make_sales_invoice(dn.name)
si.items[0].qty = 2
si.insert()
si.submit()
dn_return = make_sales_return(dn.name)
dn_return.items[0].qty = -3
dn_return.insert()
# Mimic the submit request, which reconstructs the document from client data.
frappe.get_doc(dn_return.as_dict()).submit()
dn.load_from_db()
self.assertEqual(dn.items[0].returned_qty, 3)
self.assertEqual(dn.per_billed, 100)
# Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled
so = make_sales_order(qty=10)
so_dn = create_dn_against_so(so.name, delivered_qty=5)
so_dn_return = make_sales_return(so_dn.name)
so_dn_return.items[0].qty = -2
so_dn_return.insert()
frappe.get_doc(so_dn_return.as_dict()).submit()
so_dn.load_from_db()
self.assertEqual(so_dn.items[0].returned_qty, 2)
self.assertEqual(so_dn.per_billed, 0)
# Mimic the state left behind by a return submitted before the fix
for name, per_billed in ((dn.name, 40), (so_dn.name, 50)):
frappe.db.set_value(
"Delivery Note",
name,
{"per_billed": per_billed, "status": "Partially Billed"},
update_modified=False,
)
patch.execute()
dn.load_from_db()
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
so_dn.load_from_db()
self.assertEqual(so_dn.per_billed, 0)
self.assertEqual(so_dn.status, "To Bill")
def test_dn_billing_status_case2(self):
# SO -> SI and SO -> DN1, DN2
from erpnext.selling.doctype.sales_order.sales_order import (

View File

@@ -50,6 +50,11 @@ class DeliveryTrip(Document):
"UOM Conversion Factor", {"from_uom": "Meter", "to_uom": self.default_distance_uom}, "value"
)
def after_mapping(self, source_doc):
for stop in self.delivery_stops[:]:
if not any(stop.get(df.fieldname) for df in stop.meta.fields):
self.remove(stop)
def validate(self):
if self._action == "submit" and not self.driver:
frappe.throw(_("A driver must be set to submit."))
@@ -69,7 +74,7 @@ class DeliveryTrip(Document):
def validate_stop_addresses(self):
for stop in self.delivery_stops:
if not stop.customer_address:
if stop.address and not stop.customer_address:
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
def update_status(self):

View File

@@ -7,6 +7,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, flt, now_datetime, nowdate
import erpnext
from erpnext.stock.doctype.delivery_note.delivery_note import make_delivery_trip
from erpnext.stock.doctype.delivery_trip.delivery_trip import (
get_contact_and_address,
notify_customers,
@@ -100,6 +101,32 @@ class TestDeliveryTrip(FrappeTestCase):
self.delivery_trip.save()
self.assertEqual(self.delivery_trip.status, "Completed")
def map_delivery_note_onto_trip(self, existing_stop):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
delivery_note = create_delivery_note()
trip = frappe.new_doc("Delivery Trip")
trip.append("delivery_stops", existing_stop)
return delivery_note, make_delivery_trip(delivery_note.name, trip)
def test_mapping_drops_placeholder_stop(self):
delivery_note, trip = self.map_delivery_note_onto_trip({})
self.assertEqual(len(trip.delivery_stops), 1)
self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name)
def test_mapping_keeps_partially_filled_stop(self):
_, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"})
self.assertEqual(len(trip.delivery_stops), 2)
self.assertIsNone(trip.delivery_stops[0].delivery_note)
def test_stop_without_address_throws_mandatory_error(self):
self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"})
self.assertRaises(frappe.MandatoryError, self.delivery_trip.save)
def create_address(driver):
if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):

View File

@@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", {
frm.trigger("set_parent_fields");
},
set_parent_fields(frm) {
if (frm.doc.apply_to_all_doctypes) {
let options = ["\n", frm.doc.reference_document];
istable(frm) {
frm.trigger("set_parent_fields");
},
frm.set_df_property("fetch_from_parent", "options", options);
} else if (frm.doc.document_type && frm.doc.istable) {
reference_document(frm) {
frm.trigger("set_parent_fields");
},
apply_to_all_doctypes(frm) {
frm.trigger("set_parent_fields");
},
set_parent_fields(frm) {
const { reference_document, document_type } = frm.doc;
if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) {
return set_parent_field_options(frm, []);
}
if (frm.doc.apply_to_all_doctypes) {
return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]);
} else if (document_type && frm.doc.istable) {
frappe.call({
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
args: {
child_doctype: frm.doc.document_type,
dimension_name: frm.doc.reference_document,
child_doctype: document_type,
dimension_name: reference_document,
},
callback: (r) => {
if (r.message && r.message.length) {
frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message));
} else {
frm.set_df_property("fetch_from_parent", "hidden", 1);
if (
frm.doc.reference_document !== reference_document ||
frm.doc.document_type !== document_type ||
frm.doc.apply_to_all_doctypes ||
!frm.doc.istable
) {
return;
}
return set_parent_field_options(frm, r.message || []);
},
});
}
@@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", {
});
},
});
function set_parent_field_options(frm, fields) {
frm.set_df_property("fetch_from_parent", "options", ["", ...fields]);
frm.set_df_property("fetch_from_parent", "hidden", !fields.length);
if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) {
return frm.set_value("fetch_from_parent", "");
}
}

View File

@@ -194,6 +194,7 @@ class Item(Document):
self.validate_conversion_factor()
self.validate_item_type()
self.validate_naming_series()
self.validate_shelf_life()
self.check_for_active_boms()
self.fill_customer_code()
self.check_item_tax()
@@ -344,6 +345,19 @@ class Item(Document):
).format(self.item_code)
)
def validate_shelf_life(self):
if (
self.has_batch_no
and self.has_expiry_date
and self.create_new_batch
and cint(self.shelf_life_in_days) <= 0
):
frappe.throw(
_("{0} must be greater than zero.").format(
self.get_label_from_fieldname("shelf_life_in_days")
)
)
def clear_retain_sample(self):
if not self.has_batch_no:
self.retain_sample = False

View File

@@ -41,6 +41,7 @@
},
{
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
@@ -138,7 +139,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2023-04-28 15:00:14.079306",
"modified": "2026-09-09 13:04:53.623636",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packing Slip Item",
@@ -149,4 +150,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -1138,10 +1138,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
returned_qty = flt(item_wise_returned_qty.get(item.name))
returned_amount = flt(returned_qty) * flt(item.rate)
pending_amount = flt(item.amount) - returned_amount
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
pending_amount = flt(item.amount)
total_billable_amount = abs(flt(item.amount))
# When rejected qty is billable, its value is part of the billable base too
rejected_amount = 0.0
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
pending_amount = flt(item.amount) + rejected_amount
total_billable_amount = abs(flt(item.amount) + rejected_amount)
if pending_amount > 0:
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
@@ -1151,9 +1155,7 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
total_amount = total_billed_amount
amount = item.amount
if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
amount += flt(item.rejected_qty * item.rate, item.precision("amount"))
amount = flt(item.amount) + rejected_amount
if adjust_incoming_rate:
adjusted_amt = 0.0

View File

@@ -605,6 +605,42 @@ class TestPurchaseReceipt(FrappeTestCase):
return_pr.cancel()
pr.cancel()
def test_per_billed_for_fully_rejected_receipt(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage
bill_rejected = frappe.db.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
try:
# Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse
pr = make_purchase_receipt(
received_qty=10,
qty=0,
rejected_qty=10,
rate=9.5,
rejected_warehouse="_Test Warehouse 1 - _TC",
do_not_save=True,
)
pr.items[0].warehouse = ""
pr.submit()
# Bill the rejected qty (10 x 9.5) directly against the receipt item
pr.items[0].db_set("billed_amt", 95)
update_billing_percentage(pr)
pr.load_from_db()
# Billing the rejected qty must not push per_billed above 100
self.assertEqual(pr.per_billed, 100)
self.assertEqual(pr.status, "Completed")
pr.cancel()
finally:
frappe.db.set_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected
)
def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse

View File

@@ -1534,22 +1534,28 @@ class StockEntry(StockController):
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
if self.purpose in ("Repack", "Manufacture"):
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
else:
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
if not incoming_items_cost:
return
incoming_items, basis, total_basis = self.get_additional_cost_allocation()
for d in self.get("items"):
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
d.additional_cost = 0
continue
elif not d.t_warehouse:
d.additional_cost = 0
continue
d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs
d.additional_cost = 0
if not total_basis:
return
for d in incoming_items:
d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs
def get_additional_cost_allocation(self):
if self.purpose in ("Repack", "Manufacture"):
incoming_items = [d for d in self.get("items") if d.is_finished_item]
else:
incoming_items = [d for d in self.get("items") if d.t_warehouse]
total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items)
if total_basic_amount:
return incoming_items, "basic_amount", total_basic_amount
return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items)
def update_valuation_rate(self):
for d in self.get("items"):
@@ -2066,32 +2072,20 @@ class StockEntry(StockController):
def get_gl_entries(self, warehouse_account):
gl_entries = super().get_gl_entries(warehouse_account)
if self.purpose in ("Repack", "Manufacture"):
total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
else:
total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
divide_based_on = total_basic_amount
if self.get("additional_costs") and not total_basic_amount:
# if total_basic_amount is 0, distribute additional charges based on qty
divide_based_on = sum(item.qty for item in list(self.get("items")))
incoming_items, basis, divide_based_on = self.get_additional_cost_allocation()
item_account_wise_additional_cost = {}
for t in self.get("additional_costs"):
for d in self.get("items"):
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
continue
elif not d.t_warehouse:
continue
if not divide_based_on:
continue
for d in incoming_items:
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
)
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
multiply_based_on = flt(d.get(basis))
item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += (
flt(t.amount * multiply_based_on) / divide_based_on

View File

@@ -4,7 +4,7 @@
from frappe.permissions import add_user_permission, remove_user_permission
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
from frappe.utils import add_days, cstr, flt, get_time, getdate, nowdate, nowtime, today
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -1614,10 +1614,12 @@ class TestStockEntry(FrappeTestCase):
se.insert()
se.submit()
self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items])
self.check_gl_entries(
"Stock Entry",
se.name,
sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
)
def test_conversion_factor_change(self):
@@ -1663,6 +1665,184 @@ class TestStockEntry(FrappeTestCase):
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
def test_additional_cost_distribution_manufacture_zero_valued_items(self):
se = frappe.get_doc(
doctype="Stock Entry",
purpose="Manufacture",
additional_costs=[frappe._dict(base_amount=100)],
items=[
frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10),
frappe._dict(
item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1
),
frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"),
],
)
se.distribute_additional_costs()
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
def test_additional_cost_distribution_zero_valued_items(self):
se = frappe.get_doc(
doctype="Stock Entry",
purpose="Material Receipt",
additional_costs=[frappe._dict(base_amount=100)],
items=[
frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"),
frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"),
],
)
se.distribute_additional_costs()
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([40.0, 60.0], distributed_costs)
def test_additional_cost_gl_for_zero_valued_manufacture(self):
company = "_Test Company with perpetual inventory"
rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name
fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name
receipt = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Material Receipt",
"stock_entry_type": "Material Receipt",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": rm,
"qty": 5,
"basic_rate": 0,
"uom": "Nos",
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": "Main - TCP1",
}
],
}
)
receipt.insert()
receipt.submit()
se = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Manufacture",
"stock_entry_type": "Manufacture",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": rm,
"qty": 5,
"uom": "Nos",
"s_warehouse": "Stores - TCP1",
"cost_center": "Main - TCP1",
},
{
"item_code": fg,
"qty": 5,
"uom": "Nos",
"t_warehouse": "Finished Goods - TCP1",
"is_finished_item": 1,
"cost_center": "Main - TCP1",
},
],
"additional_costs": [
{
"expense_account": "Miscellaneous Expenses - TCP1",
"amount": 500,
"description": "freight",
}
],
}
)
se.insert()
se.submit()
self.assertEqual(500.0, se.items[1].additional_cost)
self.check_gl_entries(
"Stock Entry",
se.name,
sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]),
)
def test_additional_cost_gl_matches_valuation_split(self):
company = "_Test Company with perpetual inventory"
cost_center = "_Test Additional Cost CC - TCP1"
if not frappe.db.exists("Cost Center", cost_center):
frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Additional Cost CC",
"company": company,
"is_group": 0,
"parent_cost_center": "_Test Company with perpetual inventory - TCP1",
}
).insert()
uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}]
item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name
uoms[1]["conversion_factor"] = 3
item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name
se = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Material Receipt",
"stock_entry_type": "Material Receipt",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": item_a,
"qty": 1,
"basic_rate": 0,
"uom": "Box",
"conversion_factor": 2,
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": "Main - TCP1",
},
{
"item_code": item_b,
"qty": 1,
"basic_rate": 0,
"uom": "Box",
"conversion_factor": 3,
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": cost_center,
},
],
"additional_costs": [
{
"expense_account": "Miscellaneous Expenses - TCP1",
"amount": 100,
"description": "misc",
}
],
}
)
se.insert()
se.submit()
self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items])
expense_by_cost_center = frappe.get_all(
"GL Entry",
filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"},
fields=["cost_center", "credit"],
)
self.assertEqual(
{"Main - TCP1": 40.0, cost_center: 60.0},
{d.cost_center: d.credit for d in expense_by_cost_center},
)
def test_additional_cost_distribution_non_manufacture(self):
se = frappe.get_doc(
doctype="Stock Entry",

View File

@@ -525,7 +525,10 @@ class StockReconciliation(StockController):
rate_precision = item.precision("valuation_rate")
rate = flt(item_dict.get("rate"), rate_precision)
valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None
# an unset rate means "keep the current one", an explicit zero is a real revaluation
valuation_rate = (
flt(item.valuation_rate, rate_precision) if item.valuation_rate not in ("", None) else None
)
if (
(item.qty is None or item.qty == item_dict.get("qty"))
and (valuation_rate is None or valuation_rate == rate)
@@ -568,7 +571,10 @@ class StockReconciliation(StockController):
amount_precision = item.precision("amount")
new_qty = flt(item.qty, qty_precision)
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"))
# an explicitly set zero rate is a real revaluation, don't fall back to the current rate
new_valuation_rate = flt(
item.valuation_rate if item.valuation_rate not in ("", None) else item_dict.get("rate")
)
current_qty = flt(item_dict.get("qty"), qty_precision)
current_valuation_rate = flt(item_dict.get("rate"))

View File

@@ -25,7 +25,12 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
from erpnext.stock.tests.test_utils import StockTestMixin
from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
from erpnext.stock.utils import (
get_incoming_rate,
get_stock_balance,
get_stock_value_on,
get_valuation_method,
)
class TestStockReconciliation(FrappeTestCase, StockTestMixin):
@@ -1583,6 +1588,85 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertEqual(sr.difference_amount, 100 * -1)
self.assertTrue(sr.items[0].qty == 0)
def test_difference_amount_for_zero_valuation_rate(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item("Test Item Stock Reco Zero Valuation Rate").name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=5, rate=0, do_not_save=1
)
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
# qty is unchanged, the stock is revalued from 5 x 100 to 5 x 0
self.assertEqual(sr.items[0].current_valuation_rate, 100)
self.assertEqual(sr.items[0].valuation_rate, 0)
self.assertEqual(sr.difference_amount, -500)
sr.submit()
sr.reload()
self.assertEqual(sr.difference_amount, -500)
self.assertEqual(
frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": sr.name, "is_cancelled": 0},
"stock_value_difference",
),
-500,
)
def test_no_change_row_removed_when_valuation_rate_is_blank(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item("Test Item Stock Reco Blank Valuation Rate").name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=5, rate=None, do_not_save=1
)
# a blank rate means "keep the current rate", so nothing changed on this row
self.assertRaises(EmptyStockReconciliationItemsError, sr.save)
def test_set_existing_stock_valuation_to_zero(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item("Test Item Stock Reco Set Valuation Zero").name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=50)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=10, rate=0, do_not_save=1
)
sr.items[0].allow_zero_valuation_rate = 1
# only the rate changes, the row must not be dropped as "no change"
sr.save()
self.assertEqual(len(sr.items), 1)
sr.submit()
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": sr.name, "is_cancelled": 0},
["qty_after_transaction", "valuation_rate", "stock_value"],
as_dict=True,
)
self.assertEqual(sle.qty_after_transaction, 10)
self.assertEqual(sle.valuation_rate, 0)
self.assertEqual(sle.stock_value, 0)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (10, 0.0))
def test_stock_reco_recalculate_qty_for_backdated_entry(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

View File

@@ -98,6 +98,7 @@ class SubcontractingOrder(SubcontractingController):
self.validate_service_items()
self.validate_supplied_items()
self.set_missing_values()
self.validate_with_previous_doc()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def on_submit(self):
@@ -108,6 +109,18 @@ class SubcontractingOrder(SubcontractingController):
self.update_status()
self.update_subcontracted_quantity_in_po(cancel=True)
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
"Purchase Order Item": {
"ref_dn_field": "purchase_order_item",
"compare_fields": [["project", "="]],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
}
)
def validate_purchase_order_for_subcontracting(self):
if self.purchase_order:
po = frappe.get_doc("Purchase Order", self.purchase_order)
@@ -211,10 +224,10 @@ class SubcontractingOrder(SubcontractingController):
if si.fg_item:
item = frappe.get_doc("Item", si.fg_item)
qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value(
qty, subcontracted_quantity, fg_item_qty, project = frappe.db.get_value(
"Purchase Order Item",
si.purchase_order_item,
["qty", "subcontracted_quantity", "fg_item_qty"],
["qty", "subcontracted_quantity", "fg_item_qty", "project"],
)
available_qty = flt(qty) - flt(subcontracted_quantity)
@@ -250,6 +263,7 @@ class SubcontractingOrder(SubcontractingController):
"purchase_order_item": si.purchase_order_item,
"material_request": si.material_request,
"material_request_item": si.material_request_item,
"project": project,
}
)
else:

View File

@@ -26,6 +26,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
@@ -112,6 +113,24 @@ class TestSubcontractingOrder(FrappeTestCase):
sco.load_from_db()
self.assertEqual(sco.status, "Partially Received")
def test_project_is_carried_over_from_purchase_order(self):
project = make_project({"project_name": "_Test SCO Project"}).name
po = make_subcontracted_purchase_order(project)
sco = get_mapped_subcontracting_order(source_name=po.name)
self.assertEqual(sco.project, project)
self.assertEqual(sco.items[0].project, project)
def test_project_cannot_differ_from_purchase_order(self):
project = make_project({"project_name": "_Test SCO Project"}).name
other_project = make_project({"project_name": "_Test SCO Project 2"}).name
po = make_subcontracted_purchase_order(project)
sco = get_mapped_subcontracting_order(source_name=po.name)
sco.items[0].project = other_project
self.assertRaises(frappe.ValidationError, sco.save)
def test_make_rm_stock_entry(self):
sco = get_subcontracting_order()
rm_items = get_rm_items(sco.supplied_items)
@@ -873,3 +892,31 @@ def create_subcontracting_order(**args):
sco.submit()
return sco
def make_subcontracted_purchase_order(project):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 7",
"qty": 10,
"rate": 100,
"fg_item": "Subcontracted Item SA7",
"fg_item_qty": 10,
},
]
po = create_purchase_order(
rm_items=service_items,
is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
do_not_submit=1,
)
po.project = project
for item in po.items:
item.project = project
po.save()
po.submit()
return po

View File

@@ -146,6 +146,7 @@ class SubcontractingReceipt(SubcontractingController):
self.get_scrap_items()
self.set_missing_values()
self.validate_with_previous_doc()
if self.get("_action") == "submit":
self.validate_scrap_items()
@@ -159,6 +160,24 @@ class SubcontractingReceipt(SubcontractingController):
self.set_supplied_items_expense_account()
self.set_supplied_items_cost_center()
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
"Subcontracting Order Item": {
"ref_dn_field": "subcontracting_order_item",
"compare_fields": [["project", "="]],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
"Purchase Order Item": {
"ref_dn_field": "purchase_order_item",
"compare_fields": [["project", "="]],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
}
)
def on_submit(self):
self.validate_closed_subcontracting_order()
self.validate_bom_required_qty()
@@ -962,5 +981,7 @@ def add_po_items_to_pr(scr_doc, target_doc):
"warehouse": item.warehouse,
"purchase_order": item.parent,
"purchase_order_item": item.name,
"project": item.project,
"cost_center": item.cost_center,
},
)

View File

@@ -25,6 +25,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -38,6 +39,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt,
)
from erpnext.subcontracting.doctype.subcontracting_order.test_subcontracting_order import (
make_subcontracted_purchase_order,
)
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
BOMQuantityError,
)
@@ -50,6 +54,26 @@ class TestSubcontractingReceipt(FrappeTestCase):
make_service_items()
make_bom_for_subcontracted_items()
def test_project_is_carried_over_from_subcontracting_order(self):
project = make_project({"project_name": "_Test SCR Project"}).name
po = make_subcontracted_purchase_order(project)
sco = get_subcontracting_order(po_name=po.name)
scr = make_subcontracting_receipt(sco.name)
self.assertEqual(scr.project, project)
self.assertEqual(scr.items[0].project, project)
def test_project_cannot_differ_from_subcontracting_order(self):
project = make_project({"project_name": "_Test SCR Project"}).name
other_project = make_project({"project_name": "_Test SCR Project 2"}).name
po = make_subcontracted_purchase_order(project)
sco = get_subcontracting_order(po_name=po.name)
scr = make_subcontracting_receipt(sco.name)
scr.items[0].project = other_project
self.assertRaises(frappe.ValidationError, scr.save)
def test_subcontracting(self):
set_backflush_based_on("BOM")
make_stock_entry(item_code="_Test Item", qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)