Compare commits

..

15 Commits

Author SHA1 Message Date
Mihir Kandoi
0752fcfe69 Merge pull request #57414 from frappe/mergify/bp/version-15-hotfix/pr-57413
fix: typeerror in get_batches_by_oldest for mixed batch expiry (backport #57413)
2026-07-23 18:46:43 +05:30
pandiyan
56bd024f39 fix: typeerror in get_batches_by_oldest for mixed batch expiry
sort on (expiry is none, expiry) so a null expiry_date is never
order-compared against a datetime.date, which raised typeerror in
python 3 when a warehouse held both dated and never-expiring batches.

(cherry picked from commit 62c9f8ee3e)
2026-07-23 12:53:35 +00:00
Mihir Kandoi
df936009e5 Merge pull request #57407 from frappe/mergify/bp/version-15-hotfix/pr-57400
fix: guard against missing is_your_company_address custom field on ad… (backport #57400)
2026-07-23 16:31:17 +05:30
Mihir Kandoi
ce3bd02f82 Merge pull request #57405 from mihir-kandoi/fix-stock-ageing-batch-pool-rebalance-v15
fix: rebalance batch slot values at the pooled rate when driven negative (backport #57403)
2026-07-23 16:13:44 +05:30
pandiyan
6fa522d031 fix: guard against missing is_your_company_address custom field on address
(cherry picked from commit ea3ed8b836)
2026-07-23 10:40:56 +00:00
Mihir Kandoi
3ad971d3f6 Merge pull request #57401 from mihir-kandoi/backport-51019-v15
fix: check if item is variant when creating WO from MR (backport #51019)
2026-07-23 15:58:33 +05:30
Mihir Kandoi
48082020e8 fix: rebalance batch slot values at the pooled rate when driven negative
A batch is one valuation pool, so consumption is valued at the pooled
rate while slots may carry stale intra-batch detail (e.g. units
reconciled at zero and later merged). Consuming such a slot leaves a
negative value on positive qty. Spread the pool value across the
batch's slots when that happens; non-batchwise slots pool per
warehouse.
2026-07-23 15:57:17 +05:30
Mihir Kandoi
bb1320f8df fix: add is_active filter 2026-07-23 15:39:44 +05:30
Mihir Kandoi
745513d0c2 fix: check if item is variant when creating WO from MR 2026-07-23 15:39:43 +05:30
Shllokkk
c740d7db13 Merge pull request #57394 from frappe/mergify/bp/version-15-hotfix/pr-57359
fix: respect selected BOM when creating work order for variant item (backport #57359)
2026-07-23 14:21:35 +05:30
Shllokkk
c5235af6bf fix: respect selected BOM when creating work order for variant item (#57359)
* fix: respect selected BOM when creating work order for variant item

* fix: add type hints to make_work_order

(cherry picked from commit 1132eb1a0f)
2026-07-23 08:19:36 +00:00
Krishna Pramod Shirsath
c1afc55abd Merge pull request #57379 from krishna-254/fix/reverse-journal-entry-save-button
fix: restore Save button on reverse journal entry
2026-07-23 10:17:27 +05:30
Krishna Shirsath
d9373850ad fix: restore Save button on reverse journal entry 2026-07-23 09:54:23 +05:30
Mihir Kandoi
27a48a7ab8 Merge pull request #57347 from mihir-kandoi/fix-neg-stock-cancel-replay-v15
fix: seed cancelled voucher replay from before its posting datetime (v15)
2026-07-22 13:09:42 +05:30
Mihir Kandoi
1ff297e9da fix: seed cancelled voucher replay from before its posting datetime
On cancel, update_entries_after replays every live SLE at the voucher's
posting datetime, but get_previous_sle_of_current_voucher seeded the
replay with the reversal SLE's creation, which resolves to the bucket's
own closing row. The bucket's net qty got double-counted into every
same-datetime row, so later submissions passed negative stock validation
against inflated balances, and the queued repost then rewrote correct
values with allow_negative_stock forced on, silently creating negative
stock. Backports the missing guard from eca71dce54.
2026-07-22 12:51:39 +05:30
8 changed files with 107 additions and 8 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.118.1"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -15,7 +15,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.is_your_company_address:
if self.get("is_your_company_address"):
return
return super().link_address()
@@ -26,7 +26,9 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -41,7 +41,7 @@ frappe.ui.form.on("Journal Entry", {
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
@@ -513,6 +513,13 @@ $.extend(erpnext.journal_entry, {
});
},
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];

View File

@@ -1461,7 +1461,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
@frappe.whitelist()
def make_work_order(
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
bom_no: str,
item: str,
qty: float = 0,
company: str | None = None,
project: str | None = None,
variant_items: str | list | None = None,
use_multi_level_bom: bool | None = None,
):
from erpnext import get_default_company
@@ -1470,7 +1476,8 @@ def make_work_order(
item_details = get_item_details(item, project)
if frappe.db.get_value("Item", item, "variant_of"):
# selected BOM already belongs to this variant — keep it
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
if variant_bom := frappe.db.get_value(
"BOM",
{"item": item, "is_default": 1, "docstatus": 1},

View File

@@ -298,7 +298,7 @@ def get_batches_by_oldest(item_code, warehouse):
"""Returns the oldest batch and qty for the given item_code and warehouse"""
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
batches_dates.sort(key=lambda tup: tup[1])
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates

View File

@@ -820,7 +820,10 @@ def raise_work_orders(material_request):
for d in mr.items:
if (d.stock_qty - d.ordered_qty) > 0:
if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}):
if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1, "is_active": 1}) or (
(variant_of := frappe.get_value("Item", d.item_code, "variant_of"))
and frappe.db.exists("BOM", {"item": variant_of, "is_default": 1, "is_active": 1})
):
wo_order = frappe.new_doc("Work Order")
wo_order.update(
{

View File

@@ -325,6 +325,7 @@ class FIFOSlots:
del stock_ledger_entries
self._recompute_moving_average_slots()
self._rebalance_negative_batch_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
@@ -346,6 +347,33 @@ class FIFOSlots:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _rebalance_negative_batch_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_batch_no"):
self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"])
def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None:
"""A batch is one valuation pool, so a slot driven negative by consumption
at the pooled rate is stale detail: spread the pool value over its slots."""
groups = {}
for slot in fifo_queue:
if is_batch_slot(slot):
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
groups.setdefault(key, []).append(slot)
for slots in groups.values():
has_negative_slot = any(
flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0
for slot in slots
)
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
if not has_negative_slot or total_qty <= 0:
continue
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})

View File

@@ -620,6 +620,58 @@ class TestStockAgeing(FrappeTestCase):
],
)
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
Consuming the zero-valued head slot at the pooled rate drives it
negative; slot values are then rebalanced to the batch pool rate."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Rebalance",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-REBALANCE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
make_sle("2021-12-03", "003", -4, 16, -20),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 6.0, "2021-12-01", 30.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).