From cada0669fde7e7b3fea2b68bb8a22fdcea1f74f6 Mon Sep 17 00:00:00 2001 From: Basilisa Kornel Date: Sat, 30 May 2026 23:24:00 +0300 Subject: [PATCH] fix: replace Stock Reconciliation in recalculate costs with direct ledger update ERPNext v15's Stock Reconciliation corrupts serial number status (sets to Delivered) and fails on second run with serial bundle qty mismatch. Replace the SR with targeted db.set_value calls on Plot Master, Serial No, Stock Ledger Entry, and Serial and Batch Bundle so COGS at handover matches allocated_cost without any stock movement side-effects. Also fix Plot Handover delivery note creation: always source warehouse from LandMS Settings (not SO), set incoming_rate from plot.allocated_cost, and auto-cancel any legacy blocking Stock Reconciliations before DN submit. --- .../land_acquisition/land_acquisition.py | 58 ++++++++++++------- .../doctype/plot_handover/plot_handover.py | 51 +++++++++++++++- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/landms/landms/doctype/land_acquisition/land_acquisition.py b/landms/landms/doctype/land_acquisition/land_acquisition.py index 4d48b3b..34b84cf 100644 --- a/landms/landms/doctype/land_acquisition/land_acquisition.py +++ b/landms/landms/doctype/land_acquisition/land_acquisition.py @@ -972,35 +972,51 @@ def _finish(log, cost_to_save=None): continue try: - sr = frappe.get_doc({ - "doctype": "Stock Reconciliation", - "purpose": "Stock Reconciliation", - "company": la.company, - "posting_date": today(), - "expense_account": lud_account, - "cost_center": cost_center, - "items": [{ - "item_code": item_code, - "warehouse": warehouse, - "qty": 1, - "valuation_rate": new_plot_cost, - "serial_no": plot.serial_no or plot.name, - "use_serial_batch_fields": 1, - }], - }) - sr.insert(ignore_permissions=True) - sr.submit() - frappe.db.set_value("Plot Master", plot.name, { "allocated_cost": new_plot_cost, "cost_per_sqm": flt(cost_per_sqm), }, update_modified=False) + + if plot.serial_no: + frappe.db.set_value("Serial No", plot.serial_no, "purchase_rate", new_plot_cost, update_modified=False) + + # Update original stock entry SLE + Serial and Batch Bundle + # so Repost and future DN outgoing rates use the correct cost + if plot.stock_entry: + sle_name = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": plot.stock_entry, "is_cancelled": 0}, + "name" + ) + if sle_name: + frappe.db.set_value("Stock Ledger Entry", sle_name, { + "incoming_rate": new_plot_cost, + "stock_value_difference": new_plot_cost, + }, update_modified=False) + + # Update the inward Serial and Batch Bundle avg_rate + bundle_name = frappe.db.get_value( + "Stock Entry Detail", + {"parent": plot.stock_entry, "serial_no": plot.serial_no}, + "serial_and_batch_bundle" + ) + if bundle_name: + frappe.db.set_value("Serial and Batch Bundle", bundle_name, { + "avg_rate": new_plot_cost, + "total_amount": new_plot_cost, + }, update_modified=False) + frappe.db.sql(""" + UPDATE `tabSerial and Batch Entry` + SET incoming_rate=%s + WHERE parent=%s AND serial_no=%s + """, (new_plot_cost, bundle_name, plot.serial_no)) + item_codes_touched.add(item_code) updated.append(f"{plot.name} ({old_plot_cost:.0f} → {new_plot_cost:.0f})") except Exception: - frappe.log_error(frappe.get_traceback(), f"Plot cost recalc SR failed: {plot.name}") - failures.append(f"{plot.name} — stock reconciliation failed (see Error Log)") + frappe.log_error(frappe.get_traceback(), f"Plot cost recalc failed: {plot.name}") + failures.append(f"{plot.name} — cost update failed (see Error Log)") # Repost Item Valuation — one per unique item code for ic in item_codes_touched: diff --git a/landms/landms/doctype/plot_handover/plot_handover.py b/landms/landms/doctype/plot_handover/plot_handover.py index 8189384..4b6655d 100644 --- a/landms/landms/doctype/plot_handover/plot_handover.py +++ b/landms/landms/doctype/plot_handover/plot_handover.py @@ -1,6 +1,6 @@ import frappe from frappe.model.document import Document -from frappe.utils import get_fullname, today +from frappe.utils import flt, get_fullname, today from landms.landms.doctype.land_acquisition.land_acquisition import sync_land_acquisition_plot_summary from landms.landms.doctype.plot_master.plot_master import get_plot_item_code @@ -95,6 +95,8 @@ def _ensure_delivery_note(self): if not plot.serial_no: frappe.throw(f"Plot {plot.name} is missing its Serial No.") + self._cancel_blocking_stock_reconciliations(plot.serial_no) + if contract.sales_order and frappe.db.exists("Sales Order", contract.sales_order): dn = self._make_delivery_note_from_sales_order(contract.sales_order, plot) else: @@ -125,11 +127,13 @@ def _make_delivery_note_from_sales_order(self, sales_order_name, plot): if not dn.items: frappe.throw(f"Sales Order {sales_order_name} has no deliverable rows for handover.") + inv_warehouse = frappe.db.get_single_value("LandMS Settings", "plot_inventory_warehouse") for row in dn.items: row.qty = 1 row.serial_no = plot.serial_no row.use_serial_batch_fields = 1 - row.warehouse = row.warehouse or frappe.db.get_single_value("LandMS Settings", "plot_inventory_warehouse") + row.warehouse = inv_warehouse + row.incoming_rate = flt(plot.allocated_cost) return dn @@ -147,6 +151,7 @@ def _make_delivery_note_direct(self, contract, plot, settings): "item_code": item_code, "qty": 1, "rate": plot.selling_price, + "incoming_rate": flt(plot.allocated_cost), "warehouse": settings.plot_inventory_warehouse, "serial_no": plot.serial_no, "use_serial_batch_fields": 1, @@ -156,6 +161,48 @@ def _make_delivery_note_direct(self, contract, plot, settings): } ) + def _cancel_blocking_stock_reconciliations(self, serial_no): + # Cancel ALL SRs for this LA newest-first to avoid chained dependency errors. + # A single SR can reference multiple plot serials so we must clear the whole LA. + la = frappe.db.get_value("Plot Master", self.plot, "land_acquisition") + if not la: + return + + sr_names = frappe.db.sql(""" + SELECT DISTINCT sr.name + FROM `tabStock Reconciliation` sr + JOIN `tabStock Reconciliation Item` sri ON sri.parent = sr.name + WHERE sri.serial_no IN ( + SELECT serial_no FROM `tabPlot Master` + WHERE land_acquisition = %s AND docstatus = 1 AND serial_no IS NOT NULL + ) AND sr.docstatus = 1 + ORDER BY sr.posting_date DESC, sr.name DESC + """, la, pluck="name") + + if not sr_names: + return + + for name in sr_names: + try: + sr = frappe.get_doc("Stock Reconciliation", name) + sr.flags.ignore_permissions = True + sr.cancel() + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), f"Could not cancel blocking SR {name} for serial {serial_no}") + + # Reset any plot serials that are stuck in Delivered state after SR cancellations + plot_serials = frappe.db.sql(""" + SELECT serial_no FROM `tabPlot Master` + WHERE land_acquisition = %s AND docstatus = 1 + AND serial_no IS NOT NULL AND status NOT IN ('Delivered', 'Title Closed') + """, la, pluck="serial_no") + + for sn in plot_serials: + sn_status = frappe.db.get_value("Serial No", sn, "status") + if sn_status == "Delivered": + frappe.db.set_value("Serial No", sn, "status", "Active") + def _cancel_delivery_note(self): if not self.delivery_note or not frappe.db.exists("Delivery Note", self.delivery_note): return