From 34b9fdb2e8dbb9231ac4d752794d25257831050e Mon Sep 17 00:00:00 2001 From: Basilisa Kornel Date: Sun, 31 May 2026 19:43:51 +0300 Subject: [PATCH] Replace SR-based plot cost recalculation with LCV pattern; fix serial status reset on handover Plot cost recalc now mimics ERPNext's Landed Cost Voucher internals (cancel/resubmit SLEs with via_landed_cost_voucher=True) instead of direct db.set_value, avoiding Stock Reconciliation's broken serial handling in v15. Handover serial fix simplified: instead of cancelling all SRs for the LA, reset only the one serial's status/warehouse metadata so DN validation passes without touching other plots. (cherry picked from commit 16dc393fece59341e1fb5d9f3ea07c155f400c0b) --- .../land_acquisition/land_acquisition.py | 200 ++++++++++++------ .../doctype/plot_handover/plot_handover.py | 64 +++--- 2 files changed, 165 insertions(+), 99 deletions(-) diff --git a/landms/landms/doctype/land_acquisition/land_acquisition.py b/landms/landms/doctype/land_acquisition/land_acquisition.py index 34b84cf..b7b9f1e 100644 --- a/landms/landms/doctype/land_acquisition/land_acquisition.py +++ b/landms/landms/doctype/land_acquisition/land_acquisition.py @@ -1,7 +1,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import today, flt, cint +from frappe.utils import today, flt, cint, create_batch class LandAcquisition(Document): @@ -882,7 +882,15 @@ def recalculate_plot_costs(land_acquisition): def _run_plot_cost_recalculation(land_acquisition): - """Background job: post Stock Reconciliations to update un-delivered plot valuations.""" + """Background job: create Stock Reconciliations to adjust un-delivered + plot valuations to match the current Land Acquisition cost. + + Uses ERPNext's Stock Reconciliation to properly create SLEs and GL + entries rather than manipulating ledger records directly. Stock + Reconciliation has a known limitation where serialised items are + temporarily marked as 'Delivered' during the outward SLE — we fix + the Serial No status/warehouse metadata after each batch. + """ from landms.landms.doctype.plot_master.plot_master import get_plot_item_code def _finish(log, cost_to_save=None): @@ -942,7 +950,7 @@ def _finish(log, cost_to_save=None): updated = [] skipped = [] failures = [] - item_codes_touched = set() + sr_items = [] for plot in plots: if plot.status in ("Delivered", "Title Closed"): @@ -951,6 +959,9 @@ def _finish(log, cost_to_save=None): if not plot.stock_entry: skipped.append(f"{plot.name} — not in inventory (no stock entry)") continue + if not plot.serial_no: + skipped.append(f"{plot.name} — missing serial number") + continue if not flt(plot.plot_size_sqm): skipped.append(f"{plot.name} — plot area is 0, cannot calculate cost") continue @@ -971,69 +982,124 @@ def _finish(log, cost_to_save=None): failures.append(f"{plot.name} — item code error: {e}") continue - try: - 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" + sr_items.append({ + "plot": plot, + "item_code": item_code, + "new_cost": new_plot_cost, + "old_cost": old_plot_cost, + }) + + if not sr_items: + _finish( + "Nothing to do — no plots need cost update.", + cost_to_save=new_total_cost, + ) + return + + # ── Adjust cost on each Stock Entry ──────────────────────────── + # Neither Stock Reconciliation (SerialNoWarehouseError) nor Landed + # Cost Voucher (doesn't support Stock Entry as receipt type) works + # for serialised items from Stock Entries. + # + # Instead, we replicate exactly what LCV's update_landed_cost() + # does internally (lines 244-259 of landed_cost_voucher.py): + # 1. Update item rates on the Stock Entry + # 2. Cancel SLEs with via_landed_cost_voucher=True (bypasses + # Serial and Batch Bundle validation) + # 3. Resubmit SLEs with via_landed_cost_voucher=True + # 4. Redo GL entries + # 5. Repost future SLE/GLE + # 6. Update Serial No purchase_rate + BATCH_SIZE = 50 + adjusted_se = [] + + for batch in create_batch(sr_items, BATCH_SIZE): + + for item in batch: + try: + doc = frappe.get_doc("Stock Entry", item["plot"].stock_entry) + new_rate = flt(item["new_cost"]) + + # ── Step 1: Update item rates on the Stock Entry ───── + for se_item in doc.get("items"): + if se_item.serial_no == item["plot"].serial_no or len(doc.items) == 1: + se_item.basic_rate = new_rate + se_item.basic_amount = flt(new_rate * se_item.qty) + se_item.amount = se_item.basic_amount + se_item.valuation_rate = new_rate + se_item.db_update() + break + + doc.total_incoming_value = sum( + flt(d.amount) for d in doc.get("items") if d.t_warehouse ) - 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" + doc.value_difference = doc.total_incoming_value - doc.total_outgoing_value + doc.total_amount = doc.total_incoming_value + doc.db_update() + + # ── Step 2: Update Serial No purchase_rate ──────────── + frappe.db.set_value( + "Serial No", item["plot"].serial_no, + "purchase_rate", new_rate, + update_modified=False, ) - 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 failed: {plot.name}") - failures.append(f"{plot.name} — cost update failed (see Error Log)") + # ── Step 3: Cancel SLEs (same as LCV line 247-249) ─── + # Stock Entry.update_stock_ledger() does NOT accept + # via_landed_cost_voucher, so we inline its logic and + # call make_sl_entries() directly with the flag. + doc.docstatus = 2 + sl_entries = [] + finished_item_row = doc.get_finished_item_row() + doc.get_sle_for_source_warehouse(sl_entries, finished_item_row) + doc.get_sle_for_target_warehouse(sl_entries, finished_item_row) + sl_entries.reverse() + doc.make_sl_entries( + sl_entries, + allow_negative_stock=True, + via_landed_cost_voucher=True, + ) + doc.make_gl_entries_on_cancel() - # Repost Item Valuation — one per unique item code - for ic in item_codes_touched: - try: - riv = frappe.get_doc({ - "doctype": "Repost Item Valuation", - "based_on": "Item and Warehouse", - "item_code": ic, - "warehouse": warehouse, - "posting_date": today(), - "posting_time": "00:00:00", - "company": la.company, - }) - riv.insert(ignore_permissions=True) - riv.submit() - except Exception: - frappe.log_error(frappe.get_traceback(), f"Repost Item Valuation failed: {ic}") + # ── Step 4: Resubmit SLEs (same as LCV line 252-259) ─ + doc.docstatus = 1 + doc.make_bundle_using_old_serial_batch_fields( + via_landed_cost_voucher=True, + ) + sl_entries = [] + doc.get_sle_for_source_warehouse(sl_entries, finished_item_row) + doc.get_sle_for_target_warehouse(sl_entries, finished_item_row) + doc.make_sl_entries( + sl_entries, + allow_negative_stock=True, + via_landed_cost_voucher=True, + ) + doc.make_gl_entries() + doc.repost_future_sle_and_gle(via_landed_cost_voucher=True) + + # ── Step 5: Update Plot Master ──────────────────────── + frappe.db.set_value("Plot Master", item["plot"].name, { + "allocated_cost": item["new_cost"], + "cost_per_sqm": flt(cost_per_sqm), + }, update_modified=False) + + adjusted_se.append(item["plot"].stock_entry) + updated.append( + f"{item['plot'].name} ({item['old_cost']:.0f} → {item['new_cost']:.0f})" + ) + + except Exception: + frappe.log_error( + title=f"Plot cost adjust failed: {item['plot'].name}", + message=frappe.get_traceback(), + reference_doctype="Land Acquisition", + reference_name=land_acquisition, + ) + failures.append( + f"{item['plot'].name} — cost adjustment failed (see Error Log)" + ) + + frappe.db.commit() # Build result log lines = [ @@ -1042,6 +1108,8 @@ def _finish(log, cost_to_save=None): "", f"✓ Updated: {len(updated)}", ] + if adjusted_se: + lines.append(f" Stock Entries adjusted: {len(set(adjusted_se))}") for item in updated: lines.append(f" {item}") lines += ["", f"→ Skipped: {len(skipped)}"] @@ -1057,5 +1125,11 @@ def _finish(log, cost_to_save=None): _finish(log, cost_to_save=cost_to_save) except Exception: - frappe.log_error(frappe.get_traceback(), f"Plot cost recalculation crashed: {land_acquisition}") + frappe.log_error( + title=f"Plot cost recalculation crashed: {land_acquisition}", + message=frappe.get_traceback(), + reference_doctype="Land Acquisition", + reference_name=land_acquisition, + ) _finish(f"FAILED (crash) — see Error Log for details.") + diff --git a/landms/landms/doctype/plot_handover/plot_handover.py b/landms/landms/doctype/plot_handover/plot_handover.py index 4b6655d..073e6b1 100644 --- a/landms/landms/doctype/plot_handover/plot_handover.py +++ b/landms/landms/doctype/plot_handover/plot_handover.py @@ -162,46 +162,38 @@ 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: + """Ensure the serial number is Active and in the correct warehouse + before creating the Delivery Note. + + Previous versions cancelled all Stock Reconciliations for the + land acquisition to work around a serial-status bug. That + approach was destructive — cancelling an SR undoes the cost + adjustment for ALL plots in that SR, not just the one being + delivered. + + The root cause is that Stock Reconciliation's outward SLE marks + serials as 'Delivered' and the inward SLE sometimes fails to + restore them. The correct fix is to simply reset the Serial No + metadata (status and warehouse) so the DN validation passes. + The SLE chain already shows the serial as available. + """ + if not serial_no: 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: + inv_warehouse = frappe.db.get_single_value( + "LandMS Settings", "plot_inventory_warehouse" + ) + sn_data = frappe.db.get_value( + "Serial No", serial_no, ["status", "warehouse"], as_dict=True + ) + if not sn_data: 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") + if sn_data.status != "Active" or sn_data.warehouse != inv_warehouse: + frappe.db.set_value("Serial No", serial_no, { + "status": "Active", + "warehouse": inv_warehouse, + }, update_modified=False) def _cancel_delivery_note(self): if not self.delivery_note or not frappe.db.exists("Delivery Note", self.delivery_note):