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

chore: release v15
This commit is contained in:
ruthra kumar
2026-01-13 20:31:27 +05:30
committed by GitHub
31 changed files with 575 additions and 111 deletions

View File

@@ -187,7 +187,6 @@ class GLEntry(Document):
account_type == "Profit and Loss"
and self.company == dimension.company
and dimension.mandatory_for_pl
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):
@@ -201,7 +200,6 @@ class GLEntry(Document):
account_type == "Balance Sheet"
and self.company == dimension.company
and dimension.mandatory_for_bs
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):

View File

@@ -177,6 +177,9 @@ class JournalEntry(AccountsController):
else:
return self._submit()
def before_cancel(self):
self.has_asset_adjustment_entry()
def cancel(self):
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
@@ -447,12 +450,27 @@ class JournalEntry(AccountsController):
)
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
def unlink_asset_adjustment_entry(self):
frappe.db.sql(
""" update `tabAsset Value Adjustment`
set journal_entry = null where journal_entry = %s""",
self.name,
def has_asset_adjustment_entry(self):
if self.flags.get("via_asset_value_adjustment"):
return
asset_value_adjustment = frappe.db.get_value(
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
)
if asset_value_adjustment:
frappe.throw(
_(
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def unlink_asset_adjustment_entry(self):
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)
.set(AssetValueAdjustment.journal_entry, None)
.where(AssetValueAdjustment.journal_entry == self.name)
).run()
def validate_party(self):
for d in self.get("accounts"):

View File

@@ -68,7 +68,7 @@
{
"columns": 2,
"fieldname": "total_amount",
"fieldtype": "Float",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Grand Total",
"print_hide": 1,
@@ -77,7 +77,7 @@
{
"columns": 2,
"fieldname": "outstanding_amount",
"fieldtype": "Float",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding",
"read_only": 1
@@ -85,7 +85,7 @@
{
"columns": 2,
"fieldname": "allocated_amount",
"fieldtype": "Float",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated"
},
@@ -174,7 +174,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-25 04:32:11.040025",
"modified": "2026-01-05 14:18:03.286224",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",

View File

@@ -18,12 +18,12 @@ class PaymentEntryReference(Document):
account_type: DF.Data | None
advance_voucher_no: DF.DynamicLink | None
advance_voucher_type: DF.Link | None
allocated_amount: DF.Float
allocated_amount: DF.Currency
bill_no: DF.Data | None
due_date: DF.Date | None
exchange_gain_loss: DF.Currency
exchange_rate: DF.Float
outstanding_amount: DF.Float
outstanding_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
@@ -34,7 +34,7 @@ class PaymentEntryReference(Document):
reconcile_effect_on: DF.Date | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float
total_amount: DF.Currency
# end: auto-generated types
@property

View File

@@ -133,7 +133,6 @@ class PaymentLedgerEntry(Document):
account_type == "Profit and Loss"
and self.company == dimension.company
and dimension.mandatory_for_pl
and not dimension.disabled
):
if not self.get(dimension.fieldname):
frappe.throw(
@@ -146,7 +145,6 @@ class PaymentLedgerEntry(Document):
account_type == "Balance Sheet"
and self.company == dimension.company
and dimension.mandatory_for_bs
and not dimension.disabled
):
if not self.get(dimension.fieldname):
frappe.throw(

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion
from frappe.query_builder import Case, Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
@@ -393,6 +393,9 @@ class PaymentReconciliation(Document):
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
def get_difference_amount(self, payment_entry, invoice, allocated_amount):
party_account_defaults = frappe.get_cached_value(
"Account", self.receivable_payable_account, ["account_type", "account_currency"], as_dict=True
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
@@ -400,9 +403,9 @@ class PaymentReconciliation(Document):
frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount")
)
difference_amount = 0
if frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
) != frappe.get_cached_value("Company", self.company, "default_currency"):
if party_account_defaults.get("account_currency") != frappe.get_cached_value(
"Company", self.company, "default_currency"
):
if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
"exchange_rate", 1
):
@@ -414,7 +417,14 @@ class PaymentReconciliation(Document):
invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
difference_amount_precision,
)
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
# Added If clause to handle return Adhoc payments for account type holders ("Payable")
if party_account_defaults.get("account_type") in ("Payable") and invoice.get(
"invoice_type"
) in ["Payment Entry", "Journal Entry"]:
difference_amount = allocated_amount_in_inv_rate - allocated_amount_in_ref_rate
else:
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
return difference_amount
@@ -677,6 +687,28 @@ class PaymentReconciliation(Document):
)
invoice_exchange_map.update(journals_map)
payment_entries = [
d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Payment Entry"
]
payment_entries.extend(
[d.get("reference_name") for d in payments if d.get("reference_type") == "Payment Entry"]
)
if payment_entries:
pe = frappe.qb.DocType("Payment Entry")
query = (
frappe.qb.from_(pe)
.select(
pe.name,
Case()
.when(pe.payment_type == "Receive", pe.source_exchange_rate)
.else_(pe.target_exchange_rate)
.as_("exchange_rate"),
)
.where(pe.name.isin(payment_entries))
)
payment_entries = query.run(as_list=1)
invoice_exchange_map.update(payment_entries)
return invoice_exchange_map
def validate_allocation(self):

View File

@@ -2336,6 +2336,210 @@ class TestPaymentReconciliation(FrappeTestCase):
frappe.db.set_value("Company", self.company, default_settings)
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3
amount = 1000
exchange_rate_at_payment = 100
exchange_rate_at_reverse_payment = 95
# Receive amount from customer - 1,00,000
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date, customer=customer)
pe.payment_type = "Receive"
pe.paid_from = self.debtors_eur
pe.paid_from_account_currency = "EUR"
pe.source_exchange_rate = exchange_rate_at_payment
pe.paid_amount = amount
pe.received_amount = exchange_rate_at_payment * amount
pe.paid_to = self.cash
pe.paid_to_account_currency = "INR"
pe = pe.save().submit()
# Pay amount to customer - 95,000
reverse_pe = self.create_payment_entry(
amount=amount, posting_date=transaction_date, customer=customer
)
reverse_pe.payment_type = "Pay"
reverse_pe.paid_from = self.cash
reverse_pe.paid_from_account_currency = "INR"
reverse_pe.target_exchange_rate = exchange_rate_at_reverse_payment
reverse_pe.paid_amount = exchange_rate_at_reverse_payment * amount
reverse_pe.received_amount = amount
reverse_pe.paid_to = self.debtors_eur
reverse_pe.paid_to_account_currency = "EUR"
reverse_pe.save().submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party = customer
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 1)
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a gain of 5000
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), 5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 1000
exchange_rate_at_payment = 100
exchange_rate_at_reverse_payment = 95
# Pay amount to supplier - 1,00,000
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = exchange_rate_at_payment
pe.paid_amount = exchange_rate_at_payment * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.save().submit()
# Receive amount from supplier - 95,000
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = exchange_rate_at_reverse_payment
reverse_pe.paid_amount = amount
reverse_pe.received_amount = exchange_rate_at_reverse_payment * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe = reverse_pe.save().submit()
# Reconcile payments
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(len(pr.get("payments")), 1)
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a loss of 5000
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3
amount = 1000
exchange_rate_at_payment = 95
exchange_rate_at_reverse_payment = 100
# Receive amount from customer - 95,000
je1 = self.create_journal_entry(self.cash, self.debtors_eur, amount, transaction_date)
je1.multi_currency = 1
je1.accounts[0].exchange_rate = 1
je1.accounts[0].debit_in_account_currency = exchange_rate_at_payment * amount
je1.accounts[0].debit = exchange_rate_at_payment * amount
je1.accounts[1].party_type = "Customer"
je1.accounts[1].party = customer
je1.accounts[1].exchange_rate = exchange_rate_at_payment
je1.accounts[1].credit_in_account_currency = amount
je1.accounts[1].credit = exchange_rate_at_payment * amount
je1.save()
je1.submit()
# Pay amount to customer - 1,00,000
je2 = self.create_journal_entry(self.debtors_eur, self.cash, amount, transaction_date)
je2.multi_currency = 1
je2.accounts[0].party_type = "Customer"
je2.accounts[0].party = customer
je2.accounts[0].exchange_rate = exchange_rate_at_reverse_payment
je2.accounts[0].debit_in_account_currency = amount
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
je2.accounts[1].exchange_rate = 1
je2.accounts[1].credit_in_account_currency = exchange_rate_at_reverse_payment * amount
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
je2.save()
je2.submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party = customer
pr.receivable_payable_account = self.debtors_eur
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a loss of 5000
self.assertEqual(flt(pr.allocation[0].difference_amount), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 1000
exchange_rate_at_payment = 95
exchange_rate_at_reverse_payment = 100
# Pay amount to supplier - 95,000
je1 = self.create_journal_entry(self.creditors_usd, self.cash, amount, transaction_date)
je1.multi_currency = 1
je1.accounts[0].party_type = "Supplier"
je1.accounts[0].party = self.supplier
je1.accounts[0].exchange_rate = exchange_rate_at_payment
je1.accounts[0].debit_in_account_currency = amount
je1.accounts[0].debit = exchange_rate_at_payment * amount
je1.accounts[1].exchange_rate = 1
je1.accounts[1].credit = exchange_rate_at_payment * amount
je1.accounts[1].credit_in_account_currency = exchange_rate_at_payment * amount
je1.save()
je1.submit()
# Receive amount from supplier - 1,00,000
je2 = self.create_journal_entry(self.cash, self.creditors_usd, amount, transaction_date)
je2.multi_currency = 1
je2.accounts[0].exchange_rate = 1
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
je2.accounts[0].debit_in_account_currency = exchange_rate_at_reverse_payment * amount
je2.accounts[1].party_type = "Supplier"
je2.accounts[1].party = self.supplier
je2.accounts[1].exchange_rate = exchange_rate_at_reverse_payment
je2.accounts[1].credit_in_account_currency = amount
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
je2.save()
je2.submit()
# Reconcile payments
pr = self.create_payment_reconciliation()
pr.party_type = "Supplier"
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Check the difference_amount is a gain of 5000
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -481,6 +481,7 @@ class TestPOSInvoice(unittest.TestCase):
rate=1000,
serial_no=[serial_nos[0]],
do_not_save=1,
ignore_sabb_validation=True,
)
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
@@ -956,6 +957,7 @@ class TestPOSInvoice(unittest.TestCase):
qty=1,
rate=100,
do_not_submit=True,
ignore_sabb_validation=True,
)
self.assertRaises(frappe.ValidationError, pos_inv.submit)
@@ -1097,6 +1099,7 @@ def create_pos_invoice(**args):
"posting_time": pos_inv.posting_time,
"type_of_transaction": type_of_transaction,
"do_not_submit": True,
"ignore_sabb_validation": args.ignore_sabb_validation,
}
)
).name

View File

@@ -12,6 +12,7 @@ from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_checks_for_pl_and_bs_accounts,
)
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
get_dimension_filter_map,
@@ -612,6 +613,18 @@ def update_accounting_dimensions(round_off_gle):
for dimension in dimensions:
round_off_gle[dimension] = dimension_values.get(dimension)
else:
report_type = frappe.get_cached_value("Account", round_off_gle.account, "report_type")
for dimension in get_checks_for_pl_and_bs_accounts():
if (
round_off_gle.company == dimension.company
and (
(report_type == "Profit and Loss" and dimension.mandatory_for_pl)
or (report_type == "Balance Sheet" and dimension.mandatory_for_bs)
)
and dimension.default_dimension
):
round_off_gle[dimension.fieldname] = dimension.default_dimension
def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use_company_default=False):

View File

@@ -235,26 +235,64 @@ frappe.ui.form.on("Asset", {
},
toggle_reference_doc: function (frm) {
if (frm.doc.purchase_receipt && frm.doc.purchase_invoice && frm.doc.docstatus === 1) {
frm.set_df_property("purchase_invoice", "read_only", 1);
frm.set_df_property("purchase_receipt", "read_only", 1);
} else if (frm.doc.is_existing_asset || frm.doc.is_composite_asset) {
frm.toggle_reqd("purchase_receipt", 0);
frm.toggle_reqd("purchase_invoice", 0);
} else if (frm.doc.purchase_receipt) {
// if purchase receipt link is set then set PI disabled
frm.toggle_reqd("purchase_invoice", 0);
frm.set_df_property("purchase_invoice", "read_only", 1);
} else if (frm.doc.purchase_invoice) {
// if purchase invoice link is set then set PR disabled
frm.toggle_reqd("purchase_receipt", 0);
frm.set_df_property("purchase_receipt", "read_only", 1);
} else {
frm.toggle_reqd("purchase_receipt", 1);
frm.set_df_property("purchase_receipt", "read_only", 0);
frm.toggle_reqd("purchase_invoice", 1);
frm.set_df_property("purchase_invoice", "read_only", 0);
const is_submitted = frm.doc.docstatus === 1;
const is_special_asset = frm.doc.is_existing_asset || frm.doc.is_composite_asset;
const clear_field = (field) => {
if (frm.doc[field]) {
frm.set_value(field, "");
}
};
["purchase_receipt", "purchase_receipt_item", "purchase_invoice", "purchase_invoice_item"].forEach(
(field) => {
frm.toggle_reqd(field, 0);
frm.set_df_property(field, "read_only", 0);
}
);
if (is_submitted) {
[
"purchase_receipt",
"purchase_receipt_item",
"purchase_invoice",
"purchase_invoice_item",
].forEach((field) => {
frm.set_df_property(field, "read_only", 1);
});
return;
}
if (is_special_asset) {
clear_field("purchase_receipt");
clear_field("purchase_receipt_item");
clear_field("purchase_invoice");
clear_field("purchase_invoice_item");
return;
}
if (frm.doc.purchase_receipt) {
frm.toggle_reqd("purchase_receipt_item", 1);
["purchase_invoice", "purchase_invoice_item"].forEach((field) => {
clear_field(field);
frm.set_df_property(field, "read_only", 1);
});
return;
}
if (frm.doc.purchase_invoice) {
frm.toggle_reqd("purchase_invoice_item", 1);
["purchase_receipt", "purchase_receipt_item"].forEach((field) => {
clear_field(field);
frm.set_df_property(field, "read_only", 1);
});
return;
}
frm.toggle_reqd("purchase_receipt", 1);
frm.toggle_reqd("purchase_invoice", 1);
},
make_journal_entry: function (frm) {
@@ -484,7 +522,6 @@ frappe.ui.form.on("Asset", {
} else {
frm.set_df_property("gross_purchase_amount", "read_only", 0);
}
frm.trigger("toggle_reference_doc");
},

View File

@@ -177,6 +177,7 @@
"default": "1",
"fieldname": "target_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Target Qty"
},
{
@@ -290,10 +291,10 @@
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "dimension_col_break",
@@ -324,7 +325,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-01-08 13:14:33.008458",
"modified": "2026-01-13 17:25:01.352568",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Capitalization",
@@ -362,10 +363,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -76,6 +76,7 @@ class AssetCapitalization(StockController):
naming_series: DF.Literal["ACC-ASC-.YYYY.-"]
posting_date: DF.Date
posting_time: DF.Time
project: DF.Link | None
service_items: DF.Table[AssetCapitalizationServiceItem]
service_items_total: DF.Currency
set_posting_time: DF.Check

View File

@@ -57,7 +57,7 @@ class AssetValueAdjustment(Document):
)
def on_cancel(self):
frappe.get_doc("Journal Entry", self.journal_entry).cancel()
self.cancel_asset_revaluation_entry()
self.update_asset()
add_asset_activity(
self.asset,
@@ -159,6 +159,16 @@ class AssetValueAdjustment(Document):
self.db_set("journal_entry", je.name)
def cancel_asset_revaluation_entry(self):
if not self.journal_entry:
return
revaluation_entry = frappe.get_doc("Journal Entry", self.journal_entry)
if revaluation_entry.docstatus == 1:
revaluation_entry.flags.ignore_permissions = True
revaluation_entry.flags.via_asset_value_adjustment = True
revaluation_entry.cancel()
def update_asset(self, asset_value=None):
difference_amount = self.difference_amount if self.docstatus == 1 else -1 * self.difference_amount
asset = self.update_asset_value_after_depreciation(difference_amount)

View File

@@ -794,7 +794,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
@frappe.whitelist()
def make_purchase_invoice_from_portal(purchase_order_name):
doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True)
if doc.contact_email != frappe.session.user:
if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
doc.save()
frappe.db.commit()

View File

@@ -0,0 +1,9 @@
# Version 16 Released!
ERPNext version 16 has been released!
Since it's the latest version of ERPNext, we recommend that you update to it to get the latest features, bug fixes and other improvements.
[Click here to know more about v16](https://frappe.io/erpnext/version-16)
If you're on [Frappe Cloud](https://frappe.io/cloud), [click here to learn how to update to v16](https://docs.frappe.io/cloud/sites/version-upgrade)

View File

@@ -3714,9 +3714,9 @@ def validate_child_on_delete(row, parent):
)
if flt(row.ordered_qty):
frappe.throw(
_("Row #{0}: Cannot delete item {1} which is assigned to customer's purchase order.").format(
row.idx, row.item_code
)
_(
"Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
).format(row.idx, row.item_code)
)
if parent.doctype == "Purchase Order" and flt(row.received_qty):

View File

@@ -1004,10 +1004,19 @@ class SellingController(StockController):
def set_default_income_account_for_item(obj):
for d in obj.get("items"):
if d.item_code:
if getattr(d, "income_account", None):
set_item_default(d.item_code, obj.company, "income_account", d.income_account)
"""Set income account as default for items in the transaction.
Updates the item default income account for each item in the transaction
if it differs from the company's default income account.
Args:
obj: Transaction document containing items table with income_account field
"""
company_default = frappe.get_cached_value("Company", obj.company, "default_income_account")
for d in obj.get("items", default=[]):
income_account = getattr(d, "income_account", None)
if d.item_code and income_account and income_account != company_default:
set_item_default(d.item_code, obj.company, "income_account", income_account)
def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):

View File

@@ -778,9 +778,8 @@ class TestSubcontractingController(FrappeTestCase):
row.serial_no = "ABC"
break
bundle.save()
self.assertRaises(frappe.ValidationError, bundle.save)
self.assertRaises(frappe.ValidationError, scr1.save)
bundle.load_from_db()
for row in bundle.entries:
if row.idx == 1:

View File

@@ -316,7 +316,7 @@ class WorkOrder(Document):
# already ordered qty
ordered_qty_against_so = frappe.db.sql(
"""select sum(qty) from `tabWork Order`
where production_item = %s and sales_order = %s and docstatus < 2 and name != %s""",
where production_item = %s and sales_order = %s and docstatus < 2 and status != 'Closed' and name != %s""",
(self.production_item, self.sales_order, self.name),
)[0][0]
@@ -351,15 +351,16 @@ class WorkOrder(Document):
def update_status(self, status=None):
"""Update status of work order if unknown"""
if status != "Stopped" and status != "Closed":
status = self.get_status(status)
if self.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)
if status != self.status:
self.db_set("status", status)
if status != self.status:
self.db_set("status", status)
self.update_required_items()
self.update_required_items()
return status
return status or self.status
def get_status(self, status=None):
"""Return the status based on stock entries against this work order"""
@@ -515,6 +516,9 @@ class WorkOrder(Document):
self.validate_cancel()
self.db_set("status", "Cancelled")
self.on_close_or_cancel()
def on_close_or_cancel(self):
if self.production_plan and frappe.db.exists(
"Production Plan Item Reference", {"parent": self.production_plan}
):
@@ -842,7 +846,7 @@ class WorkOrder(Document):
qty = frappe.db.sql(
f""" select sum(qty) from
`tabWork Order` where sales_order = %s and docstatus = 1 and {cond}
`tabWork Order` where sales_order = %s and docstatus = 1 and status <> 'Closed' and {cond}
""",
(self.sales_order, (self.product_bundle_item or self.production_item)),
as_list=1,
@@ -1603,8 +1607,8 @@ def close_work_order(work_order, status):
)
)
work_order.on_close_or_cancel()
work_order.update_status(status)
work_order.update_planned_qty()
frappe.msgprint(_("Work Order has been {0}").format(status))
work_order.notify_update()
return work_order.status

View File

@@ -555,10 +555,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
var item = frappe.get_doc(cdt, cdn);
var update_stock = 0, show_batch_dialog = 0;
item.weight_per_unit = 0;
item.weight_uom = '';
item.uom = null // make UOM blank to update the existing UOM when item changes
if(!item.barcode){
item.uom = null // make UOM blank to update the existing UOM when item changes
}
item.conversion_factor = 0;
if(['Sales Invoice', 'Purchase Invoice'].includes(this.frm.doc.doctype)) {
@@ -574,6 +575,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
show_batch_dialog = 0;
}
item.barcode = null;

View File

@@ -404,6 +404,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
async set_barcode(row, barcode) {
if (barcode && frappe.meta.has_field(row.doctype, this.barcode_field)) {
await frappe.model.set_value(row.doctype, row.name, this.barcode_field, barcode);
} else {
row.barcode = barcode;
}
}

View File

@@ -1875,6 +1875,7 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
& (wo.sales_order == so.name)
& (wo.sales_order_item == i.name)
& (wo.docstatus.lt(2))
& (wo.status != "Closed")
)
.run()[0][0]
)

View File

@@ -2790,6 +2790,23 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(sre_details[0].reserved_qty, so.items[0].qty)
self.assertEqual(sre_details[0].delivered_qty, dn.items[0].qty)
def test_negative_stock_with_higher_precision(self):
original_flt_precision = frappe.db.get_default("float_precision")
frappe.db.set_single_value("System Settings", "float_precision", 7)
item_code = make_item(
"Test Negative Stock High Precision Item", properties={"is_stock_item": 1, "valuation_rate": 1}
).name
dn = create_delivery_note(
item_code=item_code,
qty=0.0000010,
do_not_submit=True,
)
self.assertRaises(frappe.ValidationError, dn.submit)
frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision)
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")

View File

@@ -273,6 +273,9 @@ class MaterialRequest(BuyingController):
.groupby(doctype.material_request_item)
)
if self.material_request_type == "Manufacture":
query = query.where(doctype.status != "Closed")
mr_items_ordered_qty = frappe._dict(query.run())
return mr_items_ordered_qty

View File

@@ -383,7 +383,7 @@ class PickList(TransactionBase):
picked_items = get_picked_items_qty(packed_items, contains_packed_items=True)
self.validate_picked_qty(picked_items)
doc_updates = {}
doc_updates = {item: {"picked_qty": 0} for item in set(packed_items)}
for d in picked_items:
doc_updates[d.product_bundle_item] = {"picked_qty": flt(d.picked_qty)}
@@ -394,7 +394,7 @@ class PickList(TransactionBase):
picked_items = get_picked_items_qty(so_items)
self.validate_picked_qty(picked_items)
doc_updates = {}
doc_updates = {item: {"picked_qty": 0} for item in set(so_items)}
for d in picked_items:
doc_updates[d.sales_order_item] = {"picked_qty": flt(d.picked_qty)}

View File

@@ -116,10 +116,20 @@ class SerialandBatchBundle(Document):
return
self.allow_existing_serial_nos()
if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test:
self.validate_serial_nos_duplicate()
if self.docstatus == 1:
if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test:
self.validate_serial_nos_duplicate()
self.check_future_entries_exists()
elif (
self.has_serial_no
and self.type_of_transaction == "Outward"
and self.voucher_type != "Stock Reconciliation"
and self.voucher_no
):
self.validate_serial_no_status()
self.check_future_entries_exists()
self.set_is_outward()
self.calculate_total_qty()
self.set_warehouse()
@@ -129,6 +139,25 @@ class SerialandBatchBundle(Document):
self.calculate_qty_and_amount()
def validate_serial_no_status(self):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
invalid_serial_nos = frappe.get_all(
"Serial No",
filters={
"name": ("in", serial_nos),
"warehouse": ("!=", self.warehouse),
},
pluck="name",
)
if invalid_serial_nos:
msg = _(
"You cannot outward following {0} as either they are Delivered, Inactive or located in a different warehouse."
).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No"))
msg += "<hr>"
msg += ", ".join(sn for sn in invalid_serial_nos)
frappe.throw(msg)
def validate_voucher_detail_no(self):
if self.type_of_transaction not in ["Inward", "Outward"] or self.voucher_type in [
"Installation Note",
@@ -702,10 +731,16 @@ class SerialandBatchBundle(Document):
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries:
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
rate = 0.0
elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference:
elif (
(flt(d.incoming_rate, precision) == flt(rate, precision))
and not stock_queue
and d.qty
and d.stock_value_difference
):
continue
if is_packed_item and d.incoming_rate:
@@ -766,7 +801,7 @@ class SerialandBatchBundle(Document):
self.calculate_total_qty(save=True)
# If user has changed the rate in the child table
if self.docstatus == 0:
if self.docstatus == 0 and self.type_of_transaction == "Inward":
self.set_incoming_rate(parent=parent, row=row, save=True)
if self.docstatus == 0 and parent.get("is_return") and parent.is_new():

View File

@@ -982,6 +982,7 @@ def make_serial_batch_bundle(kwargs):
"type_of_transaction": type_of_transaction,
"company": kwargs.company or "_Test Company",
"do_not_submit": kwargs.do_not_submit,
"ignore_sabb_validation": kwargs.ignore_sabb_validation or False,
}
)

View File

@@ -382,6 +382,7 @@
"print_hide": 1
},
{
"allow_on_submit": 1,
"fieldname": "tracking_status",
"fieldtype": "Select",
"label": "Tracking Status",
@@ -440,7 +441,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2025-02-20 16:55:20.076418",
"modified": "2026-01-07 19:24:23.566312",
"modified_by": "Administrator",
"module": "Stock",
"name": "Shipment",

View File

@@ -20,9 +20,7 @@ class Shipment(Document):
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.stock.doctype.shipment_delivery_note.shipment_delivery_note import (
ShipmentDeliveryNote,
)
from erpnext.stock.doctype.shipment_delivery_note.shipment_delivery_note import ShipmentDeliveryNote
from erpnext.stock.doctype.shipment_parcel.shipment_parcel import ShipmentParcel
amended_from: DF.Link | None

View File

@@ -4,7 +4,7 @@ import frappe
from frappe import _, bold
from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series
from frappe.query_builder import Case
from frappe.query_builder.functions import CombineDatetime, Sum, Timestamp
from frappe.query_builder.functions import CombineDatetime, Max, Sum, Timestamp
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, now, nowtime, today
from pypika import Order
from pypika.terms import ExistsCriterion
@@ -616,8 +616,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
self.old_serial_nos = []
serial_nos = self.get_serial_nos()
result = self.get_serial_no_wise_incoming_rate(serial_nos)
for serial_no in serial_nos:
incoming_rate = self.get_incoming_rate_from_bundle(serial_no)
incoming_rate = result.get(serial_no)
if incoming_rate is None:
self.old_serial_nos.append(serial_no)
continue
@@ -627,44 +628,103 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
self.calculate_stock_value_from_deprecarated_ledgers()
def get_incoming_rate_from_bundle(self, serial_no) -> float:
def get_serial_no_wise_incoming_rate(self, serial_nos):
bundle = frappe.qb.DocType("Serial and Batch Bundle")
bundle_child = frappe.qb.DocType("Serial and Batch Entry")
def get_latest_based_on_posting_datetime():
# Get latest inward record based on posting datetime for each serial no
latest_posting = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)
.on(bundle.name == bundle_child.parent)
.select(
bundle_child.serial_no,
Max(CombineDatetime(bundle.posting_date, bundle.posting_time)).as_("max_posting_dt"),
)
.where(
(bundle.is_cancelled == 0)
& (bundle.docstatus == 1)
& (bundle.type_of_transaction == "Inward")
& (bundle_child.qty > 0)
& (bundle.item_code == self.sle.item_code)
& (bundle_child.warehouse == self.sle.warehouse)
& (bundle_child.serial_no.isin(serial_nos))
)
.groupby(bundle_child.serial_no)
)
# Important to exclude the current voucher to calculate correct the stock value difference
if self.sle.voucher_no:
latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no)
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
timestamp_condition = CombineDatetime(
bundle.posting_date, bundle.posting_time
) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time)
latest_posting = latest_posting.where(timestamp_condition)
latest_posting = latest_posting.as_("latest_posting")
return latest_posting
def get_latest_based_on_creation(latest_posting):
# Get latest inward record based on creation for each serial no
latest_creation = (
frappe.qb.from_(bundle)
.join(bundle_child)
.on(bundle.name == bundle_child.parent)
.join(latest_posting)
.on(
(latest_posting.serial_no == bundle_child.serial_no)
& (
latest_posting.max_posting_dt
== CombineDatetime(bundle.posting_date, bundle.posting_time)
)
)
.select(
bundle_child.serial_no,
Max(bundle.creation).as_("max_creation"),
)
.where(
(bundle.is_cancelled == 0)
& (bundle.docstatus == 1)
& (bundle.type_of_transaction == "Inward")
& (bundle_child.qty > 0)
& (bundle.item_code == self.sle.item_code)
& (bundle_child.warehouse == self.sle.warehouse)
)
.groupby(bundle_child.serial_no)
).as_("latest_creation")
return latest_creation
latest_posting = get_latest_based_on_posting_datetime()
latest_creation = get_latest_based_on_creation(latest_posting)
query = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)
.join(bundle_child)
.on(bundle.name == bundle_child.parent)
.select((bundle_child.incoming_rate * bundle_child.qty).as_("incoming_rate"))
.where(
(bundle.is_cancelled == 0)
& (bundle.docstatus == 1)
& (bundle_child.serial_no == serial_no)
& (bundle.type_of_transaction == "Inward")
& (bundle_child.qty > 0)
& (bundle.item_code == self.sle.item_code)
& (bundle_child.warehouse == self.sle.warehouse)
.join(latest_creation)
.on(
(latest_creation.serial_no == bundle_child.serial_no)
& (latest_creation.max_creation == bundle.creation)
)
.select(
bundle_child.serial_no,
bundle_child.incoming_rate,
)
.orderby(Timestamp(bundle.posting_date, bundle.posting_time), order=Order.desc)
.limit(1)
)
# Important to exclude the current voucher to calculate correct the stock value difference
if self.sle.voucher_no:
query = query.where(bundle.voucher_no != self.sle.voucher_no)
result = query.run(as_list=1)
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
timestamp_condition = CombineDatetime(
bundle.posting_date, bundle.posting_time
) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time)
query = query.where(timestamp_condition)
incoming_rate = query.run()
return flt(incoming_rate[0][0]) if incoming_rate else None
return frappe._dict(result) if result else frappe._dict({})
def get_serial_nos(self):
if self.sle.get("serial_nos"):
@@ -1131,6 +1191,9 @@ class SerialBatchCreation:
doc.submit()
else:
if self.get("ignore_sabb_validation"):
doc.flags.ignore_validate = True
doc.save()
self.validate_qty(doc)

View File

@@ -1170,7 +1170,11 @@ class update_entries_after:
diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) - flt(self.reserved_stock)
diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001:
diff_threshold = 0.0001
if self.flt_precision > 4:
diff_threshold = 10 ** (-1 * self.flt_precision)
if diff < 0 and abs(diff) > diff_threshold:
# negative stock!
exc = sle.copy().update({"diff": diff})
self.exceptions.setdefault(sle.warehouse, []).append(exc)