diff --git a/landms/hooks.py b/landms/hooks.py index 09c6800..5f8c59f 100644 --- a/landms/hooks.py +++ b/landms/hooks.py @@ -33,6 +33,11 @@ "on_cancel": "landms.landms.doctype.land_acquisition.land_acquisition.sync_costs_from_purchase_invoice", "on_update_after_submit": "landms.landms.doctype.land_acquisition.land_acquisition.sync_costs_from_purchase_invoice", }, + "Journal Entry": { + "before_save": "landms.journal_entry_hooks.before_save_journal_entry", + "on_submit": "landms.landms.doctype.land_acquisition.land_acquisition.sync_costs_from_journal_entry", + "on_cancel": "landms.landms.doctype.land_acquisition.land_acquisition.sync_costs_from_journal_entry", + }, "Payment Entry": { "validate": [ "landms.landms.doctype.land_acquisition.land_acquisition.autoset_land_acquisition_on_payment_entry", @@ -47,6 +52,9 @@ "landms.payment_sync.on_cancel_payment_entry", ], }, + "Sales Invoice": { + "before_save": "landms.sales_invoice_hooks.before_save_sales_invoice", + }, "Sales Order": { "validate": "landms.sales_order_hooks.validate_sales_order", "on_submit": "landms.sales_order_hooks.submit_sales_order", diff --git a/landms/journal_entry_hooks.py b/landms/journal_entry_hooks.py new file mode 100644 index 0000000..f5833a4 --- /dev/null +++ b/landms/journal_entry_hooks.py @@ -0,0 +1,17 @@ +"""Journal Entry doc-event hooks for LandMS.""" + +import frappe + + +def before_save_journal_entry(doc, method=None): + """Auto-fill cost_center from LandMS Settings on rows that have + land_acquisition set but are missing a cost_center.""" + + # Find the cost_center from settings (cached single-doc read) + cost_center = frappe.db.get_single_value("LandMS Settings", "cost_center") + if not cost_center: + return + + for row in (doc.accounts or []): + if row.get("land_acquisition"): + row.cost_center = cost_center diff --git a/landms/landms/doctype/land_acquisition/land_acquisition.js b/landms/landms/doctype/land_acquisition/land_acquisition.js index 00b8ed3..4b9082d 100644 --- a/landms/landms/doctype/land_acquisition/land_acquisition.js +++ b/landms/landms/doctype/land_acquisition/land_acquisition.js @@ -1,5 +1,10 @@ frappe.ui.form.on('Land Acquisition', { + payment_years(frm) { _update_period_summary(frm); }, + payment_months(frm) { _update_period_summary(frm); }, + payment_days_input(frm) { _update_period_summary(frm); }, + refresh(frm) { + _update_period_summary(frm); if (frm.doc.__islocal || !frm.doc.name) { // New doc — wipe any stale supplier tables left over from a // previously viewed Land Acquisition so the user doesn't see @@ -49,9 +54,42 @@ frappe.ui.form.on('Land Acquisition', { frappe.flags.new_pi_land_acquisition = frm.doc.name; frappe.new_doc('Purchase Invoice'); }, __('Create')); + + if (frm.doc.recalculation_in_progress) { + frm.dashboard.set_headline_alert(__('Plot cost recalculation running in background — form will refresh when done.'), 'blue'); + _start_recalculation_poll(frm); + } else { + frm.add_custom_button(__('Recalculate Plot Costs'), () => { + frappe.confirm( + __('This will update stock valuations for all un-delivered plots to match the current acquisition cost.

' + + 'Delivered plots will be skipped — check the Recalculation Log for those.

' + + 'Runs in the background. Continue?'), + () => { + frappe.call({ + method: 'landms.landms.doctype.land_acquisition.land_acquisition.recalculate_plot_costs', + args: { land_acquisition: frm.doc.name }, + callback: () => frm.reload_doc(), + }); + } + ); + }); + } } }); +function _start_recalculation_poll(frm) { + const la_name = frm.doc.name; + const poll = setInterval(() => { + frappe.db.get_value('Land Acquisition', la_name, 'recalculation_in_progress', (r) => { + if (!r || !r.recalculation_in_progress) { + clearInterval(poll); + frm.reload_doc(); + frappe.show_alert({ message: __('Plot cost recalculation complete. See Recalculation Log.'), indicator: 'green' }); + } + }); + }, 3000); +} + function refresh_cost_summary(frm) { frappe.call({ method: 'landms.landms.doctype.land_acquisition.land_acquisition.sync_land_acquisition_cost_summary', @@ -67,6 +105,7 @@ function refresh_cost_summary(frm) { total_paid_tzs: totals.paid, total_outstanding_tzs: totals.outstanding, total_unbilled_po_tzs: totals.unbilled_po, + je_billed_tzs: totals.je_billed, }; for (const [name, value] of Object.entries(fields)) { frm.doc[name] = Number(value || 0); @@ -80,6 +119,11 @@ function refresh_cost_summary(frm) { ); } + render_je_table( + frm, 'je_summary_html', + summary.je_rows || [], + 'No Journal Entries tagged to this Land Acquisition yet.' + ); render_supplier_table( frm, 'land_seller_summary_html', summary.sellers || [], @@ -108,6 +152,54 @@ function refresh_plot_counts(frm) { }); } +function render_je_table(frm, fieldname, rows, empty_message) { + const wrapper = frm.get_field(fieldname)?.$wrapper; + if (!wrapper) return; + + if (!rows.length) { + wrapper.html(`
${empty_message}
`); + return; + } + + const escape_html = (v) => frappe.utils.escape_html(String(v || '')); + const fmt = (v) => format_currency(v || 0, 'TZS'); + const link = (name) => + `${escape_html(name)}`; + + const body = rows.map(row => ` + + ${escape_html(row.posting_date || '')} + ${link(row.je_name)} + ${escape_html(row.user_remark || '')} + ${fmt(row.amount)} + + `).join(''); + + const total = rows.reduce((sum, r) => sum + flt(r.amount), 0); + + wrapper.html(` +
+ + + + + + + + + + ${body} + + + + + + +
DateJournal EntryRemarksAmount (TZS)
Total JE Billed${fmt(total)}
+
+ `); +} + function render_supplier_table(frm, fieldname, rows, empty_message) { const wrapper = frm.get_field(fieldname)?.$wrapper; if (!wrapper) return; @@ -183,3 +275,19 @@ function build_drilldown_links(row) { } return parts.join('
') || '-'; } + +function _update_period_summary(frm) { + const years = cint(frm.doc.payment_years || 0); + const months = cint(frm.doc.payment_months || 0); + const days = cint(frm.doc.payment_days_input || 0); + const total = (years * 365) + (months * 30) + days; + + const parts = []; + if (years) parts.push(`${years} year${years > 1 ? 's' : ''}`); + if (months) parts.push(`${months} month${months > 1 ? 's' : ''}`); + if (days) parts.push(`${days} day${days > 1 ? 's' : ''}`); + + const label = parts.length ? parts.join(' + ') : '0 days'; + const summary = total > 0 ? `${label} = ${total} days total` : ''; + frm.set_value('payment_period_summary', summary); +} diff --git a/landms/landms/doctype/land_acquisition/land_acquisition.json b/landms/landms/doctype/land_acquisition/land_acquisition.json index 5baf1d0..7ae819a 100644 --- a/landms/landms/doctype/land_acquisition/land_acquisition.json +++ b/landms/landms/doctype/land_acquisition/land_acquisition.json @@ -159,12 +159,48 @@ "reqd": 1, "default": "0" }, + { + "fieldname": "payment_period_section", + "fieldtype": "Section Break", + "label": "Payment Period", + "collapsible": 0 + }, + { + "fieldname": "payment_years", + "fieldtype": "Int", + "label": "Years", + "default": "0" + }, + { + "fieldname": "payment_months", + "fieldtype": "Int", + "label": "Months", + "default": "0" + }, + { + "fieldname": "payment_days_input", + "fieldtype": "Int", + "label": "Days", + "default": "30" + }, + { + "fieldname": "payment_period_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "payment_period_summary", + "fieldtype": "Data", + "label": "Total Period", + "read_only": 1, + "description": "Calculated total in days" + }, { "fieldname": "payment_completion_days", "fieldtype": "Int", "label": "Payment Completion (Days)", "reqd": 1, - "default": "30" + "default": "30", + "hidden": 1 }, { "fieldname": "plot_type_rates_section", @@ -194,7 +230,7 @@ "label": "Total Land Cost (TZS)", "read_only": 1, "bold": 1, - "description": "Sum of submitted Purchase Invoices tagged to this Land Acquisition (capitalized to the Land Under Development account). This is the cost basis Plot Master uses for allocation." + "description": "Sum of submitted Purchase Invoices and Journal Entries tagged to this Land Acquisition (capitalized to land cost accounts). This is the cost basis Plot Master uses for allocation." }, { "fieldname": "cost_per_sqm_tzs", @@ -239,6 +275,56 @@ "read_only": 1, "description": "Committed minus Billed — work ordered through Purchase Orders but not yet invoiced." }, + { + "fieldname": "je_billed_tzs", + "fieldtype": "Currency", + "label": "JE Billed (TZS)", + "read_only": 1, + "description": "Total debited to land cost accounts via Journal Entries tagged to this Land Acquisition. Included in Total Land Cost." + }, + { + "fieldname": "recalculation_section", + "fieldtype": "Section Break", + "label": "Plot Cost Recalculation", + "collapsible": 1 + }, + { + "fieldname": "last_recalculation_cost", + "fieldtype": "Currency", + "label": "Last Recalculation Cost (TZS)", + "read_only": 1, + "description": "Acquisition cost at the time the last recalculation ran successfully." + }, + { + "fieldname": "last_recalculation_date", + "fieldtype": "Datetime", + "label": "Last Recalculation Date", + "read_only": 1 + }, + { + "fieldname": "recalculation_in_progress", + "fieldtype": "Check", + "label": "Recalculation In Progress", + "read_only": 1, + "hidden": 1 + }, + { + "fieldname": "last_recalculation_log", + "fieldtype": "Text", + "label": "Recalculation Log", + "read_only": 1 + }, + { + "fieldname": "je_costs_section", + "fieldtype": "Section Break", + "label": "Journal Entry Costs", + "collapsible": 1 + }, + { + "fieldname": "je_summary_html", + "fieldtype": "HTML", + "label": "Journal Entry Costs" + }, { "fieldname": "land_seller_section", "fieldtype": "Section Break", diff --git a/landms/landms/doctype/land_acquisition/land_acquisition.py b/landms/landms/doctype/land_acquisition/land_acquisition.py index 3c5b85a..4d48b3b 100644 --- a/landms/landms/doctype/land_acquisition/land_acquisition.py +++ b/landms/landms/doctype/land_acquisition/land_acquisition.py @@ -6,6 +6,7 @@ class LandAcquisition(Document): def validate(self): + self._convert_payment_period_to_days() self._validate_area() self._validate_coordinates() self._validate_sales_defaults() @@ -162,6 +163,22 @@ def _validate_coordinates(self): if lng < -180 or lng > 180: frappe.throw(_("Longitude must be between -180 and 180.")) + def _convert_payment_period_to_days(self): + total = ( + cint(self.payment_years or 0) * 365 + + cint(self.payment_months or 0) * 30 + + cint(self.payment_days_input or 0) + ) + if total <= 0: + frappe.throw(_("Payment period must be greater than zero.")) + self.payment_completion_days = total + self.payment_period_summary = _build_period_summary( + cint(self.payment_years or 0), + cint(self.payment_months or 0), + cint(self.payment_days_input or 0), + total, + ) + def _validate_sales_defaults(self): if not (0 <= flt(self.booking_fee_percent) <= 100): frappe.throw(_("Booking Fee % must be between 0 and 100.")) @@ -205,6 +222,7 @@ def sync_land_acquisition_cost_summary(land_acquisition): "total_paid_tzs": t["paid"], "total_outstanding_tzs": t["outstanding"], "total_unbilled_po_tzs": t["unbilled_po"], + "je_billed_tzs": t["je_billed"], }, update_modified=False, ) @@ -262,6 +280,16 @@ def get_land_acquisition_cost_summary(land_acquisition): others = [s for s in suppliers if not s["is_land_seller"]] totals = _compute_totals(sellers, others, flt(la.total_area_sqm)) + plot_inv_account = frappe.db.get_single_value("LandMS Settings", "plot_inventory_account") + je_billed = _fetch_billed_from_je(land_acquisition, lud_account, plot_inv_account) + je_rows = _fetch_je_rows(land_acquisition, lud_account, plot_inv_account) + totals["je_billed"] = je_billed + if je_billed: + area = flt(la.total_area_sqm) + totals["acquisition_cost_tzs"] = flt(totals["acquisition_cost_tzs"]) + je_billed + totals["billed"] = flt(totals["billed"]) + je_billed + totals["cost_per_sqm_tzs"] = (totals["acquisition_cost_tzs"] / area) if area else 0.0 + return { "land_acquisition": land_acquisition, "total_area_sqm": flt(la.total_area_sqm), @@ -269,6 +297,7 @@ def get_land_acquisition_cost_summary(land_acquisition): "sellers": sellers, "others": others, "totals": totals, + "je_rows": je_rows, } @@ -340,6 +369,44 @@ def _fetch_billed(la_name, lud_account): ) +def _fetch_je_rows(la_name, lud_account, plot_inv_account): + """Return individual submitted JE rows tagged to this LA on land cost accounts.""" + accounts = tuple(a for a in [lud_account, plot_inv_account] if a) + if not accounts: + return [] + return frappe.db.sql(""" + SELECT + je.name AS je_name, + je.posting_date, + je.user_remark, + jea.debit_in_account_currency AS amount + FROM `tabJournal Entry Account` jea + INNER JOIN `tabJournal Entry` je ON je.name = jea.parent + WHERE jea.land_acquisition = %(la)s + AND je.docstatus = 1 + AND jea.account IN %(accounts)s + AND jea.debit_in_account_currency > 0 + ORDER BY je.posting_date DESC, je.name + """, {"la": la_name, "accounts": accounts}, as_dict=True) + + +def _fetch_billed_from_je(la_name, lud_account, plot_inv_account): + """Sum debit amounts from submitted JEs tagged with this LA on land cost accounts.""" + accounts = tuple(a for a in [lud_account, plot_inv_account] if a) + if not accounts: + return 0.0 + result = frappe.db.sql(""" + SELECT COALESCE(SUM(jea.debit_in_account_currency), 0) + FROM `tabJournal Entry Account` jea + INNER JOIN `tabJournal Entry` je ON je.name = jea.parent + WHERE jea.land_acquisition = %(la)s + AND je.docstatus = 1 + AND jea.account IN %(accounts)s + AND jea.debit_in_account_currency > 0 + """, {"la": la_name, "accounts": accounts}) + return flt(result[0][0]) if result else 0.0 + + def _fetch_paid(pi_names): """Payment Entry allocations against the given PI names. @@ -573,7 +640,7 @@ def _empty_summary(la_name): "other_committed", "other_billed", "other_paid", "other_outstanding", "other_unbilled_po", "committed", "billed", "paid", "outstanding", "unbilled_po", - "acquisition_cost_tzs", "cost_per_sqm_tzs", + "acquisition_cost_tzs", "cost_per_sqm_tzs", "je_billed", ) }, } @@ -661,6 +728,14 @@ def sync_costs_from_purchase_invoice(doc, method=None): }) +def sync_costs_from_journal_entry(doc, method=None): + _sync_many({ + row.land_acquisition + for row in (doc.get("accounts") or []) + if row.get("land_acquisition") + }) + + def autoset_land_acquisition_on_payment_entry(doc, method=None): """Tag a Payment Entry with the LA dimension by following its references. @@ -757,3 +832,214 @@ def set_land_acquisition_expense_account(doc, method=None): item.expense_account = land_account if cost_center: item.cost_center = cost_center + + +def _build_period_summary(years, months, days, total_days): + parts = [] + if years: + parts.append(f"{years} year{'s' if years > 1 else ''}") + if months: + parts.append(f"{months} month{'s' if months > 1 else ''}") + if days: + parts.append(f"{days} day{'s' if days > 1 else ''}") + label = " + ".join(parts) if parts else "0 days" + return f"{label} = {total_days} days total" + + +# ============================================================================= +# Recalculate Plot Costs +# ============================================================================= + +@frappe.whitelist() +def recalculate_plot_costs(land_acquisition): + """Enqueue a background job to update plot stock valuations to the current LA cost.""" + la = frappe.db.get_value( + "Land Acquisition", land_acquisition, + ["docstatus", "recalculation_in_progress"], + as_dict=True, + ) + if not la or la.docstatus != 1: + frappe.throw("Land Acquisition must be submitted.") + if la.recalculation_in_progress: + frappe.throw("A recalculation is already running. Please wait for it to complete.") + + frappe.db.set_value( + "Land Acquisition", land_acquisition, + "recalculation_in_progress", 1, + update_modified=False, + ) + frappe.db.commit() + + frappe.enqueue( + "landms.landms.doctype.land_acquisition.land_acquisition._run_plot_cost_recalculation", + land_acquisition=land_acquisition, + queue="long", + timeout=3600, + job_name=f"recalc_plot_costs_{land_acquisition}", + now=frappe.conf.get("developer_mode"), + ) + return {"status": "queued"} + + +def _run_plot_cost_recalculation(land_acquisition): + """Background job: post Stock Reconciliations to update un-delivered plot valuations.""" + from landms.landms.doctype.plot_master.plot_master import get_plot_item_code + + def _finish(log, cost_to_save=None): + update = { + "recalculation_in_progress": 0, + "last_recalculation_date": frappe.utils.now(), + "last_recalculation_log": log, + } + if cost_to_save is not None: + update["last_recalculation_cost"] = cost_to_save + frappe.db.set_value("Land Acquisition", land_acquisition, update, update_modified=False) + frappe.db.commit() + + try: + settings = frappe.get_single("LandMS Settings") + lud_account = settings.land_under_development_account + warehouse = settings.plot_inventory_warehouse + cost_center = settings.cost_center or "" + + if not lud_account: + _finish("FAILED: Land Under Development Account not set in LandMS Settings.") + return + if not warehouse: + _finish("FAILED: Plot Inventory Warehouse not set in LandMS Settings.") + return + + la = frappe.db.get_value( + "Land Acquisition", land_acquisition, + ["company", "total_area_sqm", "acquisition_cost_tzs", "last_recalculation_cost", "last_recalculation_log"], + as_dict=True, + ) + + if not flt(la.total_area_sqm): + _finish("FAILED: Total Area (sqm) is not set on this Land Acquisition.") + return + + new_total_cost = flt(la.acquisition_cost_tzs) + if not new_total_cost: + _finish("FAILED: Acquisition cost is zero. Submit Purchase Invoices first.") + return + + # Guard — only skip if last run was clean (no failures recorded) + prev_log = la.last_recalculation_log or "" + prev_had_failures = "FAILED" in prev_log or "✗" in prev_log + if flt(la.last_recalculation_cost) == new_total_cost and not prev_had_failures: + _finish("Nothing to do — plot costs are already up to date.", cost_to_save=new_total_cost) + return + + cost_per_sqm = new_total_cost / flt(la.total_area_sqm) + + plots = frappe.get_all( + "Plot Master", + filters={"land_acquisition": land_acquisition, "docstatus": 1}, + fields=["name", "plot_type", "plot_size_sqm", "allocated_cost", "serial_no", "stock_entry", "status"], + ) + + updated = [] + skipped = [] + failures = [] + item_codes_touched = set() + + for plot in plots: + if plot.status in ("Delivered", "Title Closed"): + skipped.append(f"{plot.name} — already delivered (manual JE adjustment needed)") + continue + if not plot.stock_entry: + skipped.append(f"{plot.name} — not in inventory (no stock entry)") + continue + if not flt(plot.plot_size_sqm): + skipped.append(f"{plot.name} — plot area is 0, cannot calculate cost") + continue + + new_plot_cost = flt(cost_per_sqm) * flt(plot.plot_size_sqm) + old_plot_cost = flt(plot.allocated_cost) + + if abs(new_plot_cost - old_plot_cost) < 0.01: + # Allocated cost is already correct — but sync cost_per_sqm if stale + if abs(flt(plot.cost_per_sqm) - flt(cost_per_sqm)) > 0.01: + frappe.db.set_value("Plot Master", plot.name, "cost_per_sqm", flt(cost_per_sqm), update_modified=False) + skipped.append(f"{plot.name} — cost unchanged ({old_plot_cost:.2f})") + continue + + try: + item_code = get_plot_item_code(plot.plot_type) + except Exception as e: + failures.append(f"{plot.name} — item code error: {e}") + 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) + 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)") + + # 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}") + + # Build result log + lines = [ + f"Run: {frappe.utils.now()}", + f"Total cost: {new_total_cost:,.0f} TZS | Cost/sqm: {cost_per_sqm:,.2f} TZS", + "", + f"✓ Updated: {len(updated)}", + ] + for item in updated: + lines.append(f" {item}") + lines += ["", f"→ Skipped: {len(skipped)}"] + for item in skipped: + lines.append(f" {item}") + if failures: + lines += ["", f"✗ Failed: {len(failures)} ← re-run to retry"] + for item in failures: + lines.append(f" {item}") + + log = "\n".join(lines) + cost_to_save = new_total_cost if not failures else None + _finish(log, cost_to_save=cost_to_save) + + except Exception: + frappe.log_error(frappe.get_traceback(), f"Plot cost recalculation crashed: {land_acquisition}") + _finish(f"FAILED (crash) — see Error Log for details.") diff --git a/landms/landms/doctype/plot_contract/plot_contract.js b/landms/landms/doctype/plot_contract/plot_contract.js index 3d92708..d39611b 100644 --- a/landms/landms/doctype/plot_contract/plot_contract.js +++ b/landms/landms/doctype/plot_contract/plot_contract.js @@ -18,6 +18,7 @@ frappe.ui.form.on('Plot Contract', { frm.doc.contract_status, colors[frm.doc.contract_status] || 'gray' ); + _render_payment_countdown(frm); render_payment_progress_bar(frm); refresh_linked_documents(frm); @@ -206,6 +207,36 @@ function render_payment_progress_bar(frm) { } } +function _render_payment_countdown(frm) { + if (frm.is_new()) return; + if (!frm.doc.payment_deadline) return; + if (['Completed', 'Terminated', 'Cancelled'].includes(frm.doc.contract_status)) return; + + const today = frappe.datetime.get_today(); + const deadline = frm.doc.payment_deadline; + const days_remaining = frappe.datetime.get_day_diff(deadline, today); + + let color, icon, message; + if (days_remaining < 0) { + color = '#fde8e8'; + icon = '🔴'; + message = `Overdue by ${Math.abs(days_remaining)} days — deadline was ${frappe.datetime.str_to_user(deadline)}`; + } else if (days_remaining <= 30) { + color = '#fff3cd'; + icon = '🟡'; + message = `${days_remaining} days remaining — deadline: ${frappe.datetime.str_to_user(deadline)}`; + } else { + color = '#d4edda'; + icon = '🟢'; + message = `${days_remaining} days remaining — deadline: ${frappe.datetime.str_to_user(deadline)}`; + } + + frm.dashboard.set_headline_alert( + `${icon} Payment Deadline: ${message}`, + days_remaining < 0 ? 'red' : days_remaining <= 30 ? 'orange' : 'green' + ); +} + function refresh_linked_documents(frm) { const wrapper = frm.get_field('linked_documents_html')?.$wrapper; if (!wrapper) return; diff --git a/landms/landms/doctype/plot_contract/plot_contract.json b/landms/landms/doctype/plot_contract/plot_contract.json index b4e1209..4e6fd93 100644 --- a/landms/landms/doctype/plot_contract/plot_contract.json +++ b/landms/landms/doctype/plot_contract/plot_contract.json @@ -35,6 +35,7 @@ "total_paid", "col_break_3", "total_outstanding", + "overpaid_amount", "payment_progress", "tab_payments", "payment_schedule_section", @@ -227,6 +228,15 @@ "read_only": 1, "bold": 1 }, + { + "fieldname": "overpaid_amount", + "fieldtype": "Currency", + "label": "Overpaid Amount (TZS)", + "read_only": 1, + "bold": 1, + "depends_on": "eval:doc.overpaid_amount > 0", + "description": "Customer has paid more than the total contract value. Accountant action required." + }, { "fieldname": "payment_progress", "fieldtype": "Select", diff --git a/landms/landms/doctype/plot_contract/plot_contract.py b/landms/landms/doctype/plot_contract/plot_contract.py index a4e9edc..2d604a0 100644 --- a/landms/landms/doctype/plot_contract/plot_contract.py +++ b/landms/landms/doctype/plot_contract/plot_contract.py @@ -7,6 +7,7 @@ from landms.landms.doctype.land_acquisition.land_acquisition import ( sync_land_acquisition_plot_summary, ) +from landms.sales_order_hooks import _post_credit_note_for_outstanding class PlotContract(Document): @@ -333,6 +334,7 @@ def sync_payment_status(self): total_paid = max(0.0, flt(si_doc.grand_total) - flt(si_doc.outstanding_amount)) total_outstanding = max(0.0, flt(si_doc.outstanding_amount)) + overpaid_amount = max(0.0, -(flt(si_doc.outstanding_amount))) paid_dates = self._get_paid_dates_by_installment(si_doc) if len(self.payment_schedule or []) != len(si_doc.payment_schedule or []) and self.docstatus == 0: @@ -343,15 +345,22 @@ def sync_payment_status(self): self.total_contract_value = flt(si_doc.grand_total) self.total_paid = total_paid self.total_outstanding = total_outstanding + self.overpaid_amount = overpaid_amount self.payment_progress = self._derive_payment_progress(total_paid, total_outstanding, si_doc=si_doc) self._persist_payment_sync_state() advance_met = self._is_advance_installment_met(si_doc) # Only auto-submit once the synced draft state is already safely saved. + # Reload before submitting to avoid TimestampMismatchError from concurrent syncs. if advance_met and self.docstatus == 0: - self.submit() - self.reload() + try: + self.reload() + if self.docstatus == 0: + self.submit() + self.reload() + except frappe.TimestampMismatchError: + pass if so_doc.get("plot_application"): app_status = frappe.db.get_value("Plot Application", so_doc.plot_application, "status") @@ -470,7 +479,26 @@ def _sync_schedule_rows_from_invoice(self, invoice, paid_dates): def _persist_payment_sync_state(self): if self.docstatus == 0: - self.save(ignore_permissions=True) + frappe.db.set_value( + "Plot Contract", + self.name, + { + "total_contract_value": flt(self.total_contract_value), + "total_paid": flt(self.total_paid), + "total_outstanding": flt(self.total_outstanding), + "overpaid_amount": flt(self.overpaid_amount), + "payment_progress": self.payment_progress or "", + "contract_status": self.contract_status or "Draft", + "booking_fee_invoice": self.booking_fee_invoice or "", + "payment_deadline": self.payment_deadline, + }, + update_modified=True, + ) + # Also persist individual payment schedule rows for draft contracts + for row in (self.payment_schedule or []): + if row.name: + row.db_update() + frappe.clear_document_cache("Plot Contract", self.name) return frappe.db.set_value( @@ -484,6 +512,7 @@ def _persist_payment_sync_state(self): "total_contract_value": flt(self.total_contract_value), "total_paid": flt(self.total_paid), "total_outstanding": flt(self.total_outstanding), + "overpaid_amount": flt(self.overpaid_amount), "payment_progress": self.payment_progress or "", "contract_status": self.contract_status or "Draft", }, @@ -580,8 +609,6 @@ def _post_completion_entries(self, settings): accounts = [{ "account": settings.customer_advance_account, "debit_in_account_currency": selling_price, - "party_type": "Customer", - "party": self.customer, "cost_center": settings.cost_center, "land_acquisition": self.land_acquisition, }] @@ -652,12 +679,14 @@ def _post_termination_journal_entry(self, settings): forfeiture_pct = flt(settings.forfeiture_percentage or 100) forfeited_amount = total_paid * forfeiture_pct / 100.0 + refund_amount = total_paid - forfeited_amount je = frappe.get_doc({ "doctype": "Journal Entry", "posting_date": today(), "company": settings.company, "voucher_type": "Journal Entry", + "lms_refund_amount": refund_amount, "user_remark": ( f"Contract termination — {forfeiture_pct:.0f}% of paid amount forfeited. " f"Contract {self.name}, Plot {self.plot}, Customer {self.customer}" @@ -666,8 +695,6 @@ def _post_termination_journal_entry(self, settings): { "account": settings.customer_advance_account, "debit_in_account_currency": forfeited_amount, - "party_type": "Customer", - "party": self.customer, "cost_center": settings.cost_center, "land_acquisition": self.land_acquisition, }, @@ -712,6 +739,11 @@ def terminate_contract(self, reason): # the forfeiture JE above already handles the accounting self._cancel_plot_invoice_on_termination() + # If the SI was left open (payments existed), issue a credit note to clear AR + si_name = self._get_plot_invoice_name() + if si_name and flt(frappe.db.get_value("Sales Invoice", si_name, "outstanding_amount") or 0) > 0: + _post_credit_note_for_outstanding(si_name) + # Cancel the linked Sales Order — the cancel_sales_order hook fires # automatically and declines the TCB control number with the reference decline URL self._cancel_linked_sales_order_on_termination() @@ -757,22 +789,29 @@ def _cancel_plot_invoice_on_termination(self): # If any payment exists, leave the SI — forfeiture JE handles the accounting def _cancel_linked_sales_order_on_termination(self): - """Cancel the Sales Order linked to this contract on termination. + """Close the Sales Order on termination — keeps docstatus=1 as audit trail. - Sets _from_termination so that cancel_sales_order skips its payment-block - and SI-cancel guards (both already handled above). The TCB control number - decline still runs automatically via the cancel_sales_order on_cancel hook. + Sets status='Closed' directly (same as the Close button) rather than + cancelling, so accounting entries are preserved. Declines the TCB control + number explicitly since the on_cancel hook no longer fires. """ + from landms.sales_order_hooks import decline_reference_for_sales_order + from frappe.utils import cstr + so_name = self.sales_order if not so_name or not frappe.db.exists("Sales Order", so_name): return so_doc = frappe.get_doc("Sales Order", so_name) if so_doc.docstatus != 1: return - so_doc.flags.ignore_permissions = True - so_doc.flags.ignore_links = True - so_doc.flags._from_termination = True - so_doc.cancel() + + # Close instead of cancel — preserves docstatus=1 and all accounting + frappe.db.set_value("Sales Order", so_name, "status", "Closed", update_modified=False) + + # Decline the TCB control number + control_number = cstr(so_doc.get("control_number") or "").strip() + if control_number: + decline_reference_for_sales_order(so_name, control_number) def _cancel_plot_application_on_termination(self): """Cancel the Plot Application so the plot is fully released.""" diff --git a/landms/landms/doctype/plot_master/plot_master.py b/landms/landms/doctype/plot_master/plot_master.py index d654e5c..a9d823d 100644 --- a/landms/landms/doctype/plot_master/plot_master.py +++ b/landms/landms/doctype/plot_master/plot_master.py @@ -265,6 +265,7 @@ def _build_plot_stock_entry_doc( "posting_date": frappe.utils.today(), "company": company, "remarks": f"Plot {plot_number} from {land_acquisition}", + "land_acquisition": land_acquisition, "difference_account": difference_account, "items": [ { diff --git a/landms/landms/doctype/tcb_api_log/tcb_api_log.json b/landms/landms/doctype/tcb_api_log/tcb_api_log.json index dca18f3..02ba81f 100644 --- a/landms/landms/doctype/tcb_api_log/tcb_api_log.json +++ b/landms/landms/doctype/tcb_api_log/tcb_api_log.json @@ -114,7 +114,16 @@ "label": "External Reference (Control Number)", "in_list_view": 1, "in_standard_filter": 1, - "search_index": 1 + "search_index": 1, + "read_only": 1 + }, + { + "fieldname": "related_ref", + "fieldtype": "Data", + "label": "Related Reference (Control Number)", + "in_standard_filter": 1, + "search_index": 1, + "read_only": 1 }, { "fieldname": "transaction_id", @@ -131,13 +140,15 @@ "fieldname": "sales_order", "fieldtype": "Link", "label": "Sales Order", - "options": "Sales Order" + "options": "Sales Order", + "read_only": 1 }, { "fieldname": "plot_contract", "fieldtype": "Link", "label": "Plot Contract", - "options": "Plot Contract" + "options": "Plot Contract", + "read_only": 1 }, { "fieldname": "payment_entry", diff --git a/landms/landms/doctype/tcb_control_number/tcb_control_number.json b/landms/landms/doctype/tcb_control_number/tcb_control_number.json index 32fddd9..91ee968 100644 --- a/landms/landms/doctype/tcb_control_number/tcb_control_number.json +++ b/landms/landms/doctype/tcb_control_number/tcb_control_number.json @@ -44,6 +44,14 @@ "search_index": 1, "read_only": 1 }, + { + "fieldname": "related_control_number", + "fieldtype": "Data", + "label": "Related Control Number", + "in_standard_filter": 1, + "search_index": 1, + "read_only": 1 + }, { "fieldname": "customer", "fieldtype": "Link", diff --git a/landms/landms/doctype/tcb_integration_settings/tcb_integration_settings.json b/landms/landms/doctype/tcb_integration_settings/tcb_integration_settings.json index 97f9f68..c4ecbd8 100644 --- a/landms/landms/doctype/tcb_integration_settings/tcb_integration_settings.json +++ b/landms/landms/doctype/tcb_integration_settings/tcb_integration_settings.json @@ -40,6 +40,12 @@ "reqd": 1, "description": "Pattern used to generate TCB control numbers. '#' = random digit (filled by secrets.randbelow). All other characters are literal. Example: 99911####00## generates 13-digit references like 9991143790087." }, + { + "fieldname": "related_control_number_pattern", + "fieldtype": "Data", + "label": "Related Control Number Pattern", + "description": "Pattern for the optional related control number. Same '#' rules as the primary pattern. Must produce values that do not collide with the primary pattern." + }, { "fieldname": "outbound_col_break", "fieldtype": "Column Break" diff --git a/landms/landms/report/landms_government_payable/landms_government_payable.js b/landms/landms/report/landms_government_payable/landms_government_payable.js index e89bc82..9e67e9d 100644 --- a/landms/landms/report/landms_government_payable/landms_government_payable.js +++ b/landms/landms/report/landms_government_payable/landms_government_payable.js @@ -1,12 +1,5 @@ -frappe.query_reports["LMS Government Payable"] = { +frappe.query_reports["LandMS Government Payable"] = { filters: [ - { - fieldname: "status", - label: "Fee Status", - fieldtype: "Select", - options: "All\nPosted\nPending", - default: "All" - }, { fieldname: "from_date", label: "From Date", @@ -17,30 +10,20 @@ frappe.query_reports["LMS Government Payable"] = { label: "To Date", fieldtype: "Date", default: frappe.datetime.get_today() + }, + { + fieldname: "land_acquisition", + label: "Land Acquisition", + fieldtype: "Link", + options: "Land Acquisition" } ], formatter: function (value, row, column, data, default_formatter) { let formatted = default_formatter(value, row, column, data); + if (!data) return formatted; - if (!data) { - return formatted; - } - - if (column.fieldname === "fee_status") { - const status = data.fee_status; - const style = status === "Posted" - ? "background:#e6fcf0;color:#1f7a3f;" - : "background:#fff5f5;color:#c92a2a;"; - return `${formatted}`; - } - - if (column.fieldname === "government_fee_withheld") { - const color = data.fee_status === "Posted" ? "#2f9e44" : "#e03131"; - return `${formatted}`; - } - - if (column.fieldname === "government_share_percent") { - return `${formatted}`; + if (column.fieldname === "govt_amount") { + return `${formatted}`; } return formatted; diff --git a/landms/landms/report/landms_government_payable/landms_government_payable.json b/landms/landms/report/landms_government_payable/landms_government_payable.json index b74bde6..d1cce41 100644 --- a/landms/landms/report/landms_government_payable/landms_government_payable.json +++ b/landms/landms/report/landms_government_payable/landms_government_payable.json @@ -4,6 +4,6 @@ "is_standard": "Yes", "module": "LandMS", "name": "LandMS Government Payable", - "ref_doctype": "Plot Contract", + "ref_doctype": "Journal Entry", "report_type": "Script Report" } \ No newline at end of file diff --git a/landms/landms/report/landms_government_payable/landms_government_payable.py b/landms/landms/report/landms_government_payable/landms_government_payable.py index e3f3d36..1953381 100644 --- a/landms/landms/report/landms_government_payable/landms_government_payable.py +++ b/landms/landms/report/landms_government_payable/landms_government_payable.py @@ -5,81 +5,74 @@ def execute(filters=None): filters = filters or {} columns = get_columns() - data = get_data(filters) + data = get_data(filters) summary = get_summary(data) - chart = get_chart(data) + chart = get_chart(data) return columns, data, None, chart, summary def get_columns(): return [ - {"label": "Contract", "fieldname": "contract", "fieldtype": "Link", "options": "Plot Contract", "width": 160}, - {"label": "Sales Order", "fieldname": "sales_order", "fieldtype": "Link", "options": "Sales Order", "width": 150}, - {"label": "Customer", "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 200}, - {"label": "Plot", "fieldname": "plot", "fieldtype": "Link", "options": "Plot Master", "width": 130}, - {"label": "Contract Value (TZS)", "fieldname": "selling_price", "fieldtype": "Float", "width": 170}, - {"label": "Govt Share %", "fieldname": "government_share_percent","fieldtype": "Percent", "width": 110}, - {"label": "Govt Fee (TZS)", "fieldname": "government_fee_withheld", "fieldtype": "Float", "width": 170}, - {"label": "Journal Entry", "fieldname": "government_fee_entry", "fieldtype": "Link", "options": "Journal Entry", "width": 170}, - {"label": "Fee Posted Date", "fieldname": "fee_posted_date", "fieldtype": "Date", "width": 140}, - {"label": "Status", "fieldname": "fee_status", "fieldtype": "Data", "width": 100}, + {"label": "Date", "fieldname": "posting_date", "fieldtype": "Date", "width": 110}, + {"label": "Journal Entry", "fieldname": "journal_entry", "fieldtype": "Link", "options": "Journal Entry", "width": 180}, + {"label": "Payment Entry", "fieldname": "payment_entry", "fieldtype": "Link", "options": "Payment Entry", "width": 180}, + {"label": "Land Acquisition","fieldname": "land_acquisition","fieldtype": "Link", "options": "Land Acquisition","width": 180}, + {"label": "Customer", "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 200}, + {"label": "Govt Share (TZS)","fieldname": "govt_amount", "fieldtype": "Currency", "width": 170}, ] def get_data(filters): - status_filter = filters.get("status") or "All" - from_date = filters.get("from_date") - to_date = filters.get("to_date") + from_date = filters.get("from_date") + to_date = filters.get("to_date") + land_acquisition = filters.get("land_acquisition") conditions = [ - "pc.docstatus = 1", - "pc.contract_status = 'Completed'", - "pc.government_fee_withheld > 0", + "je.docstatus = 1", + "(je.lms_payment_entry IS NOT NULL AND je.lms_payment_entry != '')", + "jea.credit_in_account_currency > 0", ] - - if status_filter == "Posted": - conditions.append("pc.government_fee_entry IS NOT NULL AND pc.government_fee_entry != ''") - elif status_filter == "Pending": - conditions.append("(pc.government_fee_entry IS NULL OR pc.government_fee_entry = '')") + params = {} if from_date: conditions.append("je.posting_date >= %(from_date)s") + params["from_date"] = from_date if to_date: - conditions.append("(je.posting_date <= %(to_date)s OR je.posting_date IS NULL)") + conditions.append("je.posting_date <= %(to_date)s") + params["to_date"] = to_date + if land_acquisition: + conditions.append("jea.land_acquisition = %(land_acquisition)s") + params["land_acquisition"] = land_acquisition where = " AND ".join(conditions) rows = frappe.db.sql(f""" SELECT - pc.name AS contract, - pc.sales_order, - pc.customer, - pc.plot, - pc.selling_price, - pc.government_share_percent, - pc.government_fee_withheld, - pc.government_fee_entry, - je.posting_date AS fee_posted_date - FROM `tabPlot Contract` pc - LEFT JOIN `tabJournal Entry` je - ON je.name = pc.government_fee_entry + je.name AS journal_entry, + je.posting_date, + je.lms_payment_entry AS payment_entry, + jea.land_acquisition, + pe.party AS customer, + jea.credit_in_account_currency AS govt_amount + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea + ON jea.parent = je.name + AND jea.credit_in_account_currency > 0 + LEFT JOIN `tabPayment Entry` pe + ON pe.name = je.lms_payment_entry WHERE {where} - ORDER BY je.posting_date DESC, pc.name - """, {"from_date": from_date, "to_date": to_date}, as_dict=True) + ORDER BY je.posting_date DESC, je.name + """, params, as_dict=True) data = [] for row in rows: data.append({ - "contract": row.contract, - "sales_order": row.sales_order, - "customer": row.customer, - "plot": row.plot, - "selling_price": flt(row.selling_price), - "government_share_percent": flt(row.government_share_percent), - "government_fee_withheld": flt(row.government_fee_withheld), - "government_fee_entry": row.government_fee_entry, - "fee_posted_date": row.fee_posted_date, - "fee_status": "Posted" if row.government_fee_entry else "Pending", + "posting_date": row.posting_date, + "journal_entry": row.journal_entry, + "payment_entry": row.payment_entry, + "land_acquisition": row.land_acquisition, + "customer": row.customer, + "govt_amount": flt(row.govt_amount), }) return data @@ -88,13 +81,11 @@ def get_data(filters): def get_summary(data): if not data: return [] - total_fee = sum(flt(r["government_fee_withheld"]) for r in data) - posted_fee = sum(flt(r["government_fee_withheld"]) for r in data if r["fee_status"] == "Posted") - pending_fee = total_fee - posted_fee + total = sum(flt(r["govt_amount"]) for r in data) + count = len(data) return [ - {"label": "Total Government Fee", "value": total_fee, "datatype": "Float", "indicator": "Blue"}, - {"label": "Posted to Govt Payable", "value": posted_fee, "datatype": "Float", "indicator": "Green"}, - {"label": "Still Pending", "value": pending_fee, "datatype": "Float", "indicator": "Red"}, + {"label": "Total Government Payable", "value": total, "datatype": "Currency", "indicator": "Blue"}, + {"label": "Number of Payments", "value": count, "datatype": "Int", "indicator": "Grey"}, ] @@ -102,21 +93,22 @@ def get_chart(data): if not data: return None - posted_fee = sum(flt(r["government_fee_withheld"]) for r in data if r["fee_status"] == "Posted") - pending_fee = sum(flt(r["government_fee_withheld"]) for r in data if r["fee_status"] == "Pending") - if posted_fee <= 0 and pending_fee <= 0: + by_la = {} + for row in data: + la = row.get("land_acquisition") or "Unassigned" + by_la[la] = by_la.get(la, 0) + flt(row["govt_amount"]) + + if not by_la: return None + labels = list(by_la.keys()) + values = [by_la[la] for la in labels] + return { "data": { - "labels": ["Posted", "Pending"], - "datasets": [ - { - "name": "Government Fee", - "values": [posted_fee, pending_fee], - } - ], + "labels": labels, + "datasets": [{"name": "Govt Share (TZS)", "values": values}], }, - "type": "donut", - "colors": ["#2f9e44", "#e03131"], + "type": "bar", + "colors": ["#1971c2"], } diff --git a/landms/landms/report/landms_plot_inventory/landms_plot_inventory.py b/landms/landms/report/landms_plot_inventory/landms_plot_inventory.py index 6f58ebd..2927956 100644 --- a/landms/landms/report/landms_plot_inventory/landms_plot_inventory.py +++ b/landms/landms/report/landms_plot_inventory/landms_plot_inventory.py @@ -85,8 +85,7 @@ def get_summary(data): pending_advance = sum(1 for r in data if r["status"] == "Pending Advance") reserved = sum(1 for r in data if r["status"] == "Reserved") ready_for_handover = sum(1 for r in data if r["status"] == "Ready for Handover") - delivered = sum(1 for r in data if r["status"] == "Delivered") - title_closed = sum(1 for r in data if r["status"] == "Title Closed") + delivered = sum(1 for r in data if r["status"] in ("Delivered", "Title Closed")) total_cost = sum(flt(r["allocated_cost"]) for r in data) total_price = sum(flt(r["selling_price"]) for r in data) @@ -101,7 +100,6 @@ def get_summary(data): {"label": "Reserved", "value": reserved, "datatype": "Int", "indicator": "Orange"}, {"label": "Ready for Handover", "value": ready_for_handover, "datatype": "Int", "indicator": "Cyan"}, {"label": "Delivered", "value": delivered, "datatype": "Int", "indicator": "Blue"}, - {"label": "Title Closed", "value": title_closed, "datatype": "Int", "indicator": "Purple"}, {"label": "Inventory Cost (TZS)", "value": total_cost, "datatype": "Float", "indicator": "Grey"}, {"label": "Asking Value (TZS)", "value": total_price, "datatype": "Float", "indicator": "Blue"}, {"label": "Potential Margin (TZS)", "value": total_margin, "datatype": "Float", "indicator": "Green"}, @@ -113,11 +111,12 @@ def get_chart(data): if not data: return None - status_order = ["Available", "Pending Fee", "Pending Advance", "Reserved", "Ready for Handover", "Delivered", "Title Closed"] + status_order = ["Available", "Pending Fee", "Pending Advance", "Reserved", "Ready for Handover", "Delivered"] status_counts = {status: 0 for status in status_order} for row in data: - if row["status"] in status_counts: - status_counts[row["status"]] += 1 + status = "Delivered" if row["status"] == "Title Closed" else row["status"] + if status in status_counts: + status_counts[status] += 1 labels = [status for status in status_order if status_counts[status] > 0] values = [status_counts[status] for status in labels] @@ -132,7 +131,6 @@ def get_chart(data): "Reserved": "#f08c00", "Ready for Handover": "#15aabf", "Delivered": "#1c7ed6", - "Title Closed": "#7b2cbf", } return { diff --git a/landms/patches.txt b/landms/patches.txt index 6bd4b6b..c449bd7 100644 --- a/landms/patches.txt +++ b/landms/patches.txt @@ -3,3 +3,4 @@ [post_model_sync] landms.patches.setup_data.create_roles landms.patches.setup_data.create_payment_terms +landms.patches.recalculate_la_costs_include_je diff --git a/landms/patches/custom_fields/custom_fields_json/01_sales_order.json b/landms/patches/custom_fields/custom_fields_json/01_sales_order.json index 46c9f71..ddd795d 100644 --- a/landms/patches/custom_fields/custom_fields_json/01_sales_order.json +++ b/landms/patches/custom_fields/custom_fields_json/01_sales_order.json @@ -119,6 +119,65 @@ "insert_after": "lms_terms_col", "read_only": 1 }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "tcb_ref_section", + "fieldtype": "Section Break", + "label": "Bank Reference Settings", + "insert_after": "payment_deadline", + "depends_on": "eval:doc.plot", + "collapsible": 1 + }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "payment_option", + "fieldtype": "Select", + "label": "Payment Type", + "options": "\nPartial\nExact", + "insert_after": "tcb_ref_section", + "depends_on": "eval:doc.plot", + "description": "Partial — customer can pay any amount. Exact — must pay the full amount at once. Leave blank if unsure." + }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "include_amount", + "fieldtype": "Check", + "label": "Send Expected Amount to Bank", + "insert_after": "payment_option", + "default": "0", + "depends_on": "eval:doc.plot", + "description": "Tick to tell the bank the total amount expected for this sale." + }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "include_expire_date", + "fieldtype": "Check", + "label": "Stop Payments After Deadline", + "insert_after": "include_amount", + "default": "0", + "depends_on": "eval:doc.plot", + "description": "Tick to prevent the bank from accepting payments after the payment deadline date above." + }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "include_related_ref", + "fieldtype": "Check", + "label": "Generate Related Reference Number", + "insert_after": "include_expire_date", + "default": "0", + "depends_on": "eval:doc.plot", + "description": "Tick if a second reference number is needed for this sale." + }, { "doctype": "Custom Field", "dt": "Sales Order", @@ -155,6 +214,19 @@ "in_list_view": 1, "in_standard_filter": 1 }, + { + "doctype": "Custom Field", + "dt": "Sales Order", + "module": "LandMS", + "fieldname": "related_control_number", + "fieldtype": "Data", + "label": "Related TCB Control Number", + "insert_after": "control_number", + "read_only": 1, + "no_copy": 1, + "depends_on": "eval:doc.related_control_number", + "description": "Generated on submit when Generate Related Control Number is ticked. Sent to TCB as relatedRef." + }, { "doctype": "Custom Field", "dt": "Sales Order", diff --git a/landms/patches/custom_fields/custom_fields_json/06_journal_entry.json b/landms/patches/custom_fields/custom_fields_json/06_journal_entry.json new file mode 100644 index 0000000..ad64f74 --- /dev/null +++ b/landms/patches/custom_fields/custom_fields_json/06_journal_entry.json @@ -0,0 +1,40 @@ +[ + { + "doctype": "Custom Field", + "dt": "Journal Entry", + "module": "LandMS", + "fieldname": "lms_section", + "fieldtype": "Section Break", + "label": "LandMS", + "insert_after": "user_remark", + "collapsible": 0, + "depends_on": "eval:doc.lms_refund_amount > 0" + }, + { + "doctype": "Custom Field", + "dt": "Journal Entry", + "module": "LandMS", + "fieldname": "lms_refund_amount", + "fieldtype": "Currency", + "label": "Refund Due to Customer (TZS)", + "insert_after": "lms_section", + "read_only": 1, + "bold": 1, + "depends_on": "eval:doc.lms_refund_amount > 0", + "description": "Amount to be returned to the customer after forfeiture deduction. Calculated as total paid minus forfeited amount." + }, + { + "doctype": "Custom Field", + "dt": "Journal Entry", + "module": "LandMS", + "fieldname": "lms_payment_entry", + "fieldtype": "Link", + "label": "LandMS Payment Entry", + "options": "Payment Entry", + "insert_after": "lms_refund_amount", + "read_only": 1, + "no_copy": 1, + "depends_on": "eval:doc.lms_payment_entry", + "description": "Payment Entry that triggered this government share journal entry." + } +] diff --git a/landms/patches/recalculate_la_costs_include_je.py b/landms/patches/recalculate_la_costs_include_je.py new file mode 100644 index 0000000..da19a07 --- /dev/null +++ b/landms/patches/recalculate_la_costs_include_je.py @@ -0,0 +1,23 @@ +import frappe +from landms.landms.doctype.land_acquisition.land_acquisition import sync_land_acquisition_cost_summary + + +def execute(): + """Recalculate all submitted Land Acquisition costs to include Journal Entry amounts. + + Deployed when JE cost sync was added. Runs once at bench migrate so all + existing LAs reflect the correct cost (PI + JE) before the hook takes over + for future changes. + """ + la_names = frappe.db.get_all( + "Land Acquisition", + filters={"docstatus": 1}, + pluck="name", + ) + + for name in la_names: + try: + sync_land_acquisition_cost_summary(name) + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), f"LA cost recalc failed: {name}") diff --git a/landms/payment_sync.py b/landms/payment_sync.py index 68dec9b..8accf46 100644 --- a/landms/payment_sync.py +++ b/landms/payment_sync.py @@ -79,11 +79,13 @@ def validate_payment_entry(doc, method=None): def on_submit_payment_entry(doc, method=None): _ensure_first_advance_plot_invoices(doc) - _sync_landms_payment_entry_state(doc) + _post_government_share_je(doc) + _enqueue_plot_payment_sync(doc, "submit") def on_cancel_payment_entry(doc, method=None): - _sync_landms_payment_entry_state(doc) + _cancel_government_share_je(doc) + _enqueue_plot_payment_sync(doc, "cancel") def sync_plot_contract_from_payment_entry(doc, method=None): @@ -91,6 +93,67 @@ def sync_plot_contract_from_payment_entry(doc, method=None): _sync_landms_payment_entry_state(doc) +def _enqueue_plot_payment_sync(doc, event): + """Run payment sync synchronously for each related plot SO. + Only fires for plot sale invoices — safe to call on any Payment Entry. + """ + related = _get_related_landms_documents_from_payment_entry(doc) + for so_name in sorted(related["sales_orders"]): + try: + _run_plot_payment_sync(so_name, pe_name=doc.name) + except Exception: + frappe.log_error(frappe.get_traceback(), f"plot_pay sync failed for {so_name}") + + +def _run_plot_payment_sync(so_name, pe_name=None): + """Sync SO + SI + Contract for one plot Sales Order. + Only runs for is_plot_sale_invoice SOs — safe to ignore all others. + """ + if not frappe.db.exists("Sales Order", so_name): + return + + inv_name = frappe.db.get_value("Sales Order", so_name, "plot_sales_invoice") + if not inv_name: + return + if not frappe.db.get_value("Sales Invoice", inv_name, "is_plot_sale_invoice"): + return + + _sync_sales_order_from_plot_invoice(so_name) + _sync_si_schedule_for_so(so_name, inv_name) + + contract_name = frappe.db.get_value("Sales Order", so_name, "plot_contract") + if contract_name and frappe.db.exists("Plot Contract", contract_name): + _sync_contract_after_payment(contract_name, pe_name=pe_name) + + +def _sync_si_schedule_for_so(so_name, inv_name): + """Sync the Sales Invoice payment schedule rows using the SO's booking_fee_percent.""" + booking_fee_pct = flt(frappe.db.get_value("Sales Order", so_name, "booking_fee_percent") or 0) + invoice = frappe.get_doc("Sales Invoice", inv_name) + if not getattr(invoice, "is_plot_sale_invoice", 0): + return + _sync_payment_schedule_rows_from_invoice(invoice, invoice, booking_fee_percent=booking_fee_pct) + + +def _sync_contract_after_payment(contract_name, pe_name=None): + """Sync Plot Contract payment status after a payment. + Reads pe.unallocated_amount to detect overpayment (only for plot sale PEs). + """ + contract = frappe.get_doc("Plot Contract", contract_name) + contract.sync_payment_status() + + # Overpayment: if the PE had more money than the SI outstanding, the + # excess sits in pe.unallocated_amount — show it on the contract. + if pe_name: + unallocated = flt(frappe.db.get_value("Payment Entry", pe_name, "unallocated_amount") or 0) + if unallocated > 0: + frappe.db.set_value( + "Plot Contract", contract_name, + "overpaid_amount", unallocated, + update_modified=False, + ) + + def _build_pe_references_for_invoice(si, total_allocated: float) -> list[dict]: """Build PE reference rows that allocate payment across SI payment schedule terms. @@ -470,7 +533,7 @@ def _link_invoice_items_to_sales_order_rows(so, invoice): frappe.db.set_value("Sales Invoice Item", item.name, updates, update_modified=False) -def _sync_payment_schedule_rows_from_invoice(parent_doc, invoice): +def _sync_payment_schedule_rows_from_invoice(parent_doc, invoice, booking_fee_percent=None): """Sync SO payment schedule rows using booking_fee_percent and SI header outstanding_amount as the source of truth. @@ -490,7 +553,10 @@ def _sync_payment_schedule_rows_from_invoice(parent_doc, invoice): return total_paid = max(0.0, grand_total - flt(invoice.outstanding_amount)) - booking_fee_percent = flt(parent_doc.get("booking_fee_percent") or 0) + if booking_fee_percent is None: + booking_fee_percent = flt(parent_doc.get("booking_fee_percent") or 0) + else: + booking_fee_percent = flt(booking_fee_percent) # Single row — full amount, no advance split if len(target_rows) == 1 or booking_fee_percent <= 0 or booking_fee_percent >= 100: @@ -619,3 +685,105 @@ def _payment_reference_exists(invoice_name: str, reference_no: str) -> bool: (reference_no, invoice_name), ) return bool(rows) + + +# ---------------------------------------------------------------------- # +# Government share JE # +# ---------------------------------------------------------------------- # + +def _post_government_share_je(pe_doc): + """Post government share JE for each plot sale payment. + + Dr Customer Advances Account (reducing deferred revenue) + Cr Government Payable Account (recording govt share payable) + Amount = government_share_percent × paid_amount + """ + if pe_doc.docstatus != 1: + return + + so_name, govt_pct, land_acquisition = _get_govt_share_fields_from_pe(pe_doc) + if not so_name or flt(govt_pct) <= 0: + return + + settings = frappe.get_single("LandMS Settings") + if not settings.government_payable_account or not settings.customer_advance_account: + return + + govt_amount = flt(pe_doc.paid_amount) * flt(govt_pct) / 100.0 + if govt_amount <= 0: + return + + je = frappe.get_doc({ + "doctype": "Journal Entry", + "posting_date": pe_doc.posting_date or today(), + "company": pe_doc.company, + "voucher_type": "Journal Entry", + "lms_payment_entry": pe_doc.name, + "user_remark": ( + f"Government share {flt(govt_pct):.2f}% on payment {pe_doc.name} " + f"— Sales Order {so_name}" + ), + "accounts": [ + { + "account": settings.customer_advance_account, + "debit_in_account_currency": govt_amount, + "cost_center": settings.cost_center, + "land_acquisition": land_acquisition or "", + }, + { + "account": settings.government_payable_account, + "credit_in_account_currency": govt_amount, + "cost_center": settings.cost_center, + "land_acquisition": land_acquisition or "", + }, + ], + }) + je.insert(ignore_permissions=True) + je.submit() + + +def _cancel_government_share_je(pe_doc): + """Cancel the government share JE linked to this Payment Entry.""" + je_name = frappe.db.get_value( + "Journal Entry", + {"lms_payment_entry": pe_doc.name, "docstatus": 1}, + "name", + ) + if not je_name: + return + je = frappe.get_doc("Journal Entry", je_name) + je.cancel() + + +def _get_govt_share_fields_from_pe(pe_doc): + """Return (sales_order_name, government_share_percent, land_acquisition) from PE references.""" + for row in pe_doc.get("references") or []: + if row.reference_doctype == "Sales Invoice": + si = frappe.db.get_value( + "Sales Invoice", row.reference_name, + ["is_plot_sale_invoice", "plot_contract"], + as_dict=True, + ) + if not si or not si.is_plot_sale_invoice: + continue + so_name = frappe.db.get_value( + "Sales Invoice Item", + {"parent": row.reference_name}, + "sales_order", + ) + if not so_name: + continue + govt_pct, land_acquisition = frappe.db.get_value( + "Sales Order", so_name, + ["government_share_percent", "land_acquisition"], + ) or (0, "") + return so_name, flt(govt_pct), land_acquisition + + if row.reference_doctype == "Sales Order": + govt_pct, land_acquisition = frappe.db.get_value( + "Sales Order", row.reference_name, + ["government_share_percent", "land_acquisition"], + ) or (0, "") + return row.reference_name, flt(govt_pct), land_acquisition + + return None, 0, "" diff --git a/landms/public/js/sales_order.js b/landms/public/js/sales_order.js index add45f8..75889b4 100644 --- a/landms/public/js/sales_order.js +++ b/landms/public/js/sales_order.js @@ -1,4 +1,9 @@ frappe.ui.form.on('Sales Order', { + refresh(frm) { + _render_so_payment_countdown(frm); + _render_so_close_button(frm); + }, + setup(frm) { frm.set_query('plot_application', () => ({ filters: { @@ -59,3 +64,61 @@ frappe.ui.form.on('Sales Order', { }); } }); + +function _render_so_close_button(frm) { + if (frm.is_new()) return; + if (!frm.doc.plot) return; + if (frm.doc.docstatus !== 1) return; + if (['Closed', 'Cancelled'].includes(frm.doc.status)) return; + + setTimeout(() => { + frm.remove_custom_button(__('Close'), __('Status')); + frm.remove_custom_button(__('Hold'), __('Status')); + }, 10); + + frm.add_custom_button(__('Close Sales Order'), () => { + frappe.confirm( + __('Close this Sales Order? The plot will be released and the TCB reference declined. This cannot be undone.'), + () => { + frappe.call({ + method: 'landms.sales_order_hooks.close_sales_order', + args: { sales_order_name: frm.doc.name }, + freeze: true, + freeze_message: __('Closing Sales Order...'), + callback(r) { + if (r.exc) return; + frm.reload_doc(); + } + }); + } + ); + }, __('Actions')); +} + +function _render_so_payment_countdown(frm) { + if (frm.is_new()) return; + if (!frm.doc.payment_deadline) return; + if (!frm.doc.plot) return; + if (frm.doc.docstatus === 2) return; + if (['Closed', 'Completed'].includes(frm.doc.status)) return; + + frm.dashboard.clear_headline(); + + const today = frappe.datetime.get_today(); + const deadline = frm.doc.payment_deadline; + const days_remaining = frappe.datetime.get_day_diff(deadline, today); + + let indicator, message; + if (days_remaining < 0) { + indicator = 'red'; + message = `🔴 Payment overdue by ${Math.abs(days_remaining)} days — deadline was ${frappe.datetime.str_to_user(deadline)}`; + } else if (days_remaining <= 30) { + indicator = 'orange'; + message = `🟡 ${days_remaining} days remaining to complete payment — deadline: ${frappe.datetime.str_to_user(deadline)}`; + } else { + indicator = 'green'; + message = `🟢 ${days_remaining} days remaining to complete payment — deadline: ${frappe.datetime.str_to_user(deadline)}`; + } + + frm.dashboard.set_headline_alert(message, indicator); +} diff --git a/landms/sales_invoice_hooks.py b/landms/sales_invoice_hooks.py new file mode 100644 index 0000000..0b953a5 --- /dev/null +++ b/landms/sales_invoice_hooks.py @@ -0,0 +1,41 @@ +"""Sales Invoice doc-event hooks for LandMS plot sale invoices.""" + +import frappe + + +def before_save_sales_invoice(doc, method=None): + if not doc.get("is_plot_sale_invoice"): + return + + # ERPNext re-enables deferred revenue from the item master on every validate. + # Clear it so our mechanism (Customer Advances + revenue JE on full payment) + # is not disrupted. + for item in (doc.items or []): + item.enable_deferred_revenue = 0 + item.deferred_revenue_account = "" + item.service_start_date = None + item.service_end_date = None + + # Restore the Balance row if ERPNext's validate collapsed the schedule to + # a single Advance row. For plot SIs the SO links forward (SO.plot_sales_invoice), + # so look it up by name if sales_order_reference is not set. + so_ref = doc.get("sales_order_reference") or ( + frappe.db.get_value("Sales Order", {"plot_sales_invoice": doc.name, "docstatus": 1}, "name") + if doc.name else None + ) + if so_ref and len(doc.get("payment_schedule") or []) == 1: + so_schedule = frappe.get_all( + "Payment Schedule", + filters={"parent": so_ref, "parenttype": "Sales Order"}, + fields=["payment_term", "description", "due_date", "invoice_portion", "payment_amount"], + order_by="idx asc", + ) + if len(so_schedule) >= 2: + row2 = so_schedule[1] + doc.append("payment_schedule", { + "payment_term": row2.payment_term, + "description": row2.description, + "due_date": row2.due_date, + "invoice_portion": row2.invoice_portion, + "payment_amount": row2.payment_amount, + }) diff --git a/landms/sales_order_hooks.py b/landms/sales_order_hooks.py index 142ca1c..eaa1828 100644 --- a/landms/sales_order_hooks.py +++ b/landms/sales_order_hooks.py @@ -121,6 +121,7 @@ def submit_sales_order(doc, method=None): control_number = _ensure_control_number(doc) _create_registry_row(doc, control_number) + _ensure_related_control_number(doc) _link_application_to_sales_order(doc) contract_name = _ensure_draft_plot_contract(doc) if contract_name and doc.get("plot_contract") != contract_name: @@ -177,6 +178,43 @@ def cancel_sales_order(doc, method=None): _release_plot_if_no_active_application(doc) +@frappe.whitelist() +def close_sales_order(sales_order_name): + """Close a plot Sales Order with no payments — releases plot, declines TCB CN, cancels application.""" + doc = frappe.get_doc("Sales Order", sales_order_name) + if not _is_landms_sales_order(doc): + frappe.throw("This Sales Order is not a LandMS plot sale.") + if doc.docstatus != 1: + frappe.throw("Only submitted Sales Orders can be closed.") + if doc.status == "Closed": + frappe.throw("This Sales Order is already closed.") + + _block_close_if_paid(doc) + _post_draft_contract_forfeiture_je(doc) + _clear_application_sales_order_link(doc) + _cancel_unpaid_plot_sales_invoice(doc) + if doc.get("forfeiture_entry"): + si_name = doc.get("plot_sales_invoice") + if si_name: + _post_credit_note_for_outstanding(si_name) + _delete_draft_plot_contract(doc) + + control_number = cstr(doc.get("control_number") or "").strip() + if control_number: + result = decline_reference_for_sales_order(doc.name, control_number) + if not result.get("ok") and result.get("block_cancel"): + frappe.throw( + f"TCB Decline call failed for control number {control_number} and " + f"the Decline Failure Policy is set to 'Block Cancel'. " + f"Close aborted. Detail: {result.get('message')}" + ) + + _cancel_plot_application(doc) + _release_plot_if_no_active_application(doc) + + doc.db_set("status", "Closed", update_modified=False) + + def ensure_plot_sales_invoice_for_sales_order( sales_order_name: str, *, @@ -491,9 +529,9 @@ def _ensure_plot_sales_invoice(doc, contract_name, *, posting_date: str | None = return existing_invoice_name # Find an existing plot SI for THIS customer+plot whose SO is still active. - # Audit-trail SIs from cancelled SOs (left ds=1 by Path B) must not be reused: - # they belong to a different sale (possibly a different customer, and certainly - # a different SO) and would cross-contaminate payment allocation. + # Audit-trail SIs from cancelled or closed SOs must not be reused: they belong + # to a different sale and would cross-contaminate payment allocation. + # Closed SOs keep docstatus=1 so we must also exclude status='Closed'. candidates = frappe.db.sql( """ SELECT si.name @@ -505,7 +543,7 @@ def _ensure_plot_sales_invoice(doc, contract_name, *, posting_date: str | None = AND si.is_plot_sale_invoice = 1 AND si.is_return = 0 AND si.docstatus = 1 - AND (so.name IS NULL OR so.docstatus != 2) + AND (so.name IS NULL OR (so.docstatus = 1 AND so.status NOT IN ('Closed', 'Cancelled'))) LIMIT 1 """, (doc.plot, doc.customer), @@ -653,15 +691,30 @@ def _block_manual_control_number(doc): ) -def _create_registry_row(doc, control_number: str): +def _create_registry_row(doc, control_number: str, related_control_number: str = ""): create_or_get_registry( control_number=control_number, sales_order=doc.name, customer=doc.customer, amount=flt(doc.grand_total), + related_control_number=related_control_number, ) +def _ensure_related_control_number(doc): + if not cint(doc.get("include_related_ref")): + return + existing = cstr(doc.get("related_control_number") or "").strip() + if existing and is_valid_control_number(existing, related=True): + return + related_cn = generate_control_number(doc.name, related=True) + doc.db_set("related_control_number", related_cn, update_modified=False) + doc.related_control_number = related_cn + primary_cn = cstr(doc.get("control_number") or "").strip() + if primary_cn and frappe.db.exists("TCB Control Number", primary_cn): + frappe.db.set_value("TCB Control Number", primary_cn, "related_control_number", related_cn) + + def _register_with_tcb(doc, control_number: str): """Register the control number with TCB synchronously during SO submit. @@ -749,6 +802,19 @@ def _block_cancel_if_paid(doc): ) +def _block_close_if_paid(doc): + submitted_contract = frappe.db.get_value( + "Plot Contract", + {"sales_order": doc.name, "docstatus": 1}, + "name", + ) + if submitted_contract: + frappe.throw( + f"Sales Order {doc.name} cannot be closed — Plot Contract {submitted_contract} " + "has been submitted. Use Plot Contract → Terminate Contract instead." + ) + + def _cancel_unpaid_plot_sales_invoice(doc): # Termination flow already handled the SI before calling SO cancel if doc.flags.get("_from_termination"): @@ -770,9 +836,8 @@ def _cancel_unpaid_plot_sales_invoice(doc): return if flt(invoice.outstanding_amount) < flt(invoice.grand_total): - frappe.throw( - f"Sales Order {doc.name} cannot be cancelled because plot invoice {invoice.name} has payments." - ) + # Invoice has payments — leave open as audit trail, credit note will zero the outstanding + return invoice.cancel() @@ -793,6 +858,8 @@ def _delete_draft_plot_contract(doc): for name in candidates: if frappe.db.get_value("Plot Contract", name, "docstatus") == 0: frappe.delete_doc("Plot Contract", name, ignore_permissions=True, force=True) + if doc.get("plot_contract") == name: + doc.db_set("plot_contract", "", update_modified=False) def _find_draft_plot_contract(doc): @@ -831,12 +898,14 @@ def _post_draft_contract_forfeiture_je(doc): settings = frappe.get_single("LandMS Settings") forfeiture_pct = flt(settings.forfeiture_percentage or 100) forfeited_amount = total_paid * forfeiture_pct / 100.0 + refund_amount = total_paid - forfeited_amount je = frappe.get_doc({ "doctype": "Journal Entry", "posting_date": today(), "company": settings.company, "voucher_type": "Journal Entry", + "lms_refund_amount": refund_amount, "user_remark": ( f"Sales Order {doc.name} cancelled before Plot Contract was submitted — " f"{forfeiture_pct:.0f}% of paid amount forfeited. " @@ -846,12 +915,14 @@ def _post_draft_contract_forfeiture_je(doc): { "account": settings.customer_advance_account, "debit_in_account_currency": forfeited_amount, - "party_type": "Customer", - "party": doc.customer, + "cost_center": settings.cost_center, + "land_acquisition": doc.get("land_acquisition") or "", }, { "account": settings.forfeited_deposits_account, "credit_in_account_currency": forfeited_amount, + "cost_center": settings.cost_center, + "land_acquisition": doc.get("land_acquisition") or "", }, ], }) @@ -860,6 +931,71 @@ def _post_draft_contract_forfeiture_je(doc): doc.db_set("forfeiture_entry", je.name) +def _post_credit_note_for_outstanding(si_name): + """Create a return Sales Invoice (credit note) for the unpaid outstanding balance. + + Called after SO close or contract termination when a partial payment existed and + the original SI was deliberately left open as an audit trail. The credit note + reverses only the outstanding portion — it does not touch allocated payments. + + Accounting: Dr Customer Advances / Cr AR (mirrors the original SI income account). + """ + if not si_name or not frappe.db.exists("Sales Invoice", si_name): + return None + + si_doc = frappe.get_doc("Sales Invoice", si_name) + outstanding = flt(si_doc.outstanding_amount) + if outstanding <= 0: + return None + + if not si_doc.get("items"): + return None + + settings = frappe.get_single("LandMS Settings") + original_item = si_doc.items[0] + + credit_note = frappe.get_doc({ + "doctype": "Sales Invoice", + "is_return": 1, + "return_against": si_name, + "customer": si_doc.customer, + "company": si_doc.company, + "posting_date": today(), + "due_date": today(), + "plot": si_doc.get("plot"), + "land_acquisition": si_doc.get("land_acquisition"), + "plot_contract": si_doc.get("plot_contract"), + "is_plot_sale_invoice": 1, + "update_stock": 0, + "is_not_vfd_invoice": 1, + "allocate_advances_automatically": 0, + "update_outstanding_for_self": 0, + "remarks": f"Credit note — sale cancelled, reversing outstanding balance on {si_name}", + "items": [{ + "item_code": original_item.item_code, + "item_name": original_item.item_name, + "qty": -1, + "uom": original_item.uom, + "stock_uom": original_item.stock_uom or original_item.uom, + "conversion_factor": 1, + "rate": outstanding, + "income_account": settings.customer_advance_account, + "cost_center": settings.cost_center, + "land_acquisition": si_doc.get("land_acquisition") or "", + }], + }) + + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + credit_note.insert(ignore_permissions=True) + credit_note.submit() + finally: + frappe.set_user(original_user) + + return credit_note.name + + def _cancel_plot_application(doc): """Cascade-cancel the linked Plot Application so the plot is released. @@ -875,6 +1011,7 @@ def _cancel_plot_application(doc): return app = frappe.get_doc("Plot Application", app_name) app.flags.from_sales_order_cancel = True + app.flags.ignore_links = True app.cancel() diff --git a/landms/tcb.py b/landms/tcb.py index bb8919c..c0c0889 100644 --- a/landms/tcb.py +++ b/landms/tcb.py @@ -54,17 +54,23 @@ # ---------------------------------------------------------------------- # -def _get_pattern() -> str: +def _get_pattern(related: bool = False) -> str: """Read the control number pattern from TCB Integration Settings. Falls back to DEFAULT_PATTERN if the setting doc doesn't exist or the field is empty. We never raise here — generation should be possible even before the user has touched the settings. """ + field = "related_control_number_pattern" if related else "control_number_pattern" try: - pattern = frappe.db.get_single_value("TCB Integration Settings", "control_number_pattern") + pattern = frappe.db.get_single_value("TCB Integration Settings", field) except Exception: pattern = None + if related and not (pattern or "").strip(): + frappe.throw( + "Related Control Number Pattern is not configured in TCB Integration Settings. " + "Set it before generating a related control number." + ) return (pattern or DEFAULT_PATTERN).strip() @@ -82,12 +88,12 @@ def _pattern_to_regex(pattern: str) -> re.Pattern: return re.compile("^" + "".join(parts) + "$") -def is_valid_control_number(value: str, pattern: str | None = None) -> bool: +def is_valid_control_number(value: str, pattern: str | None = None, *, related: bool = False) -> bool: """Strict pattern check — same shape used by generation.""" value = cstr(value).strip() if not value: return False - pattern = (pattern or _get_pattern()).strip() + pattern = (pattern or _get_pattern(related=related)).strip() if not pattern: return False return bool(_pattern_to_regex(pattern).match(value)) @@ -120,11 +126,12 @@ def _fill_pattern(pattern: str) -> str: def _control_number_exists(candidate: str) -> bool: """True if the candidate is already in use anywhere we care about. - Two sources of truth: - - tabSales Order.control_number (the field that drives lookups) - - tabTCB Control Number (the registry — DB-level unique by name) + Sources of truth: + - tabSales Order.control_number (primary CN field) + - tabSales Order.related_control_number (related CN field) + - tabTCB Control Number (the registry — DB-level unique by name) - Both are checked. Wrapped in try/except so a missing column on a fresh + All are checked. Wrapped in try/except so a missing column on a fresh install can't break generation. """ try: @@ -133,28 +140,41 @@ def _control_number_exists(candidate: str) -> bool: return True except Exception: pass + try: + if frappe.db.has_column("Sales Order", "related_control_number"): + if frappe.db.exists("Sales Order", {"related_control_number": candidate}): + return True + except Exception: + pass try: if frappe.db.exists("TCB Control Number", candidate): return True except Exception: pass + try: + if frappe.db.has_column("TCB Control Number", "related_control_number"): + if frappe.db.exists("TCB Control Number", {"related_control_number": candidate}): + return True + except Exception: + pass return False -def generate_control_number(sales_order_name: str | None = None) -> str: +def generate_control_number(sales_order_name: str | None = None, *, related: bool = False) -> str: """Generate a unique TCB control number. - The pattern is read from TCB Integration Settings. The result is checked - against both the Sales Order column and the TCB Control Number registry - to avoid collisions. Retries up to GENERATION_RETRIES times before throwing. + The pattern is read from TCB Integration Settings (primary or related pattern + depending on the `related` flag). The result is checked against Sales Order + fields and the TCB Control Number registry to avoid collisions. `sales_order_name` is accepted for symmetry with the legacy signature; it does NOT influence the generated value (which is fully random). """ - pattern = _get_pattern() + pattern = _get_pattern(related=related) if "#" not in pattern: + label = "Related Control Number Pattern" if related else "Control Number Pattern" frappe.throw( - "Control Number Pattern in TCB Integration Settings is missing '#'. " + f"{label} in TCB Integration Settings is missing '#'. " "Cannot generate randomized references." ) @@ -203,6 +223,7 @@ def _get_tcb_settings() -> dict[str, Any]: "outbound_mode": "Off", "inbound_mode": "Off", "control_number_pattern": DEFAULT_PATTERN, + "related_control_number_pattern": "", "auto_apply_callback_payments": 0, "auto_apply_reconciliation_payments": 0, "reconciliation_enabled": 0, @@ -227,6 +248,7 @@ def _get_tcb_settings() -> dict[str, Any]: "outbound_mode": get_value("outbound_mode") or "Off", "inbound_mode": get_value("inbound_mode") or "Off", "control_number_pattern": get_value("control_number_pattern") or DEFAULT_PATTERN, + "related_control_number_pattern": (get_value("related_control_number_pattern") or "").strip(), "auto_apply_callback_payments": get_value("auto_apply_callback_payments"), "auto_apply_reconciliation_payments": get_value("auto_apply_reconciliation_payments"), "reconciliation_enabled": get_value("reconciliation_enabled"), @@ -286,19 +308,21 @@ def should_auto_apply_reconciliation_payments() -> bool: def create_or_get_registry(*, control_number: str, sales_order: str, - customer: str | None = None, amount: float = 0): + customer: str | None = None, amount: float = 0, + related_control_number: str = ""): """Idempotent registry creation. Returns the registry doc.""" if frappe.db.exists("TCB Control Number", control_number): return frappe.get_doc("TCB Control Number", control_number) doc = frappe.get_doc({ - "doctype": "TCB Control Number", - "control_number": control_number, - "sales_order": sales_order, - "customer": customer or "", - "amount": flt(amount), - "status": "Generated", - "generated_at": now(), - "last_event": f"Generated for {sales_order}", + "doctype": "TCB Control Number", + "control_number": control_number, + "sales_order": sales_order, + "customer": customer or "", + "amount": flt(amount), + "related_control_number": related_control_number or "", + "status": "Generated", + "generated_at": now(), + "last_event": f"Generated for {sales_order}", }) doc.insert(ignore_permissions=True) return doc @@ -318,33 +342,32 @@ def _get_registry(control_number: str): def _build_reference_payload(*, control_number: str, sales_order_name: str = "") -> dict[str, Any]: - """Build the full outbound payload for TCB Reference Create. - - TCB requires: partnerCode, profileID, reference, name, mobile, message. - Per TCB docs (form-urlencoded POST over HTTPS): - - partnerCode: STRING — assigned by TCB during registration - - profileID: LONG — collection account number - - reference: STRING — control number generated by partner - - name: STRING — payer name - - mobile: LONG — payer mobile (255...) - - message: STRING — descriptive message / remark + """Build the full outbound payload for TCB Reference Create (v0.6). + + Mandatory: partnerCode, profileID, reference, name, mobile, message. + Optional: relatedRef, paymentOption, expireDate. + Sent as application/x-www-form-urlencoded POST. """ settings = _get_tcb_settings() customer_name = "" mobile = "" message = f"Plot payment - {control_number}" + related_ref = "" + payment_option = "" + expire_date = "" + amount = 0.0 if sales_order_name and frappe.db.exists("Sales Order", sales_order_name): so = frappe.db.get_value( "Sales Order", sales_order_name, - ["customer", "customer_name", "contact_mobile", "contact_phone"], + ["customer", "customer_name", "contact_mobile", "contact_phone", + "related_control_number", "payment_option", "include_amount", + "payment_deadline", "include_expire_date", "grand_total"], as_dict=True, ) if so: customer_name = so.customer_name or so.customer or "" message = f"Plot payment for {customer_name} - {control_number}" - # Cascade: Customer mobile_no → SO contact_mobile → SO contact_phone - # → linked Contact mobile/phone. TCB rejects empty mobile. mobile = ( frappe.db.get_value("Customer", so.customer, "mobile_no") or so.contact_mobile @@ -352,8 +375,14 @@ def _build_reference_payload(*, control_number: str, sales_order_name: str = "") or _get_contact_mobile(so.customer) or "0" ) + related_ref = cstr(so.related_control_number or "").strip() + payment_option = cstr(so.payment_option or "").strip() + if cint(so.include_expire_date) and so.payment_deadline: + expire_date = cstr(so.payment_deadline).strip() + if cint(so.include_amount): + amount = flt(so.grand_total) - return { + payload = { "partnerCode": settings.get("partner_code") or "", "profileID": settings.get("profile_id") or "", "reference": control_number, @@ -361,6 +390,15 @@ def _build_reference_payload(*, control_number: str, sales_order_name: str = "") "mobile": mobile, "message": message, } + if related_ref: + payload["relatedRef"] = related_ref + if payment_option: + payload["paymentOption"] = payment_option + if expire_date: + payload["expireDate"] = expire_date + if amount > 0: + payload["amount"] = amount + return payload def _get_contact_mobile(customer: str) -> str: @@ -390,6 +428,11 @@ def register_reference_for_sales_order(sales_order_name: str, control_number: st payload = _build_reference_payload(control_number=control_number, sales_order_name=sales_order_name) endpoint = _masked_reference_endpoint(settings) + plot_contract = "" + if sales_order_name and frappe.db.exists("Sales Order", sales_order_name): + plot_contract = frappe.db.get_value("Sales Order", sales_order_name, "plot_contract") or "" + related_ref = payload.get("relatedRef") or "" + if not enabled or outbound_mode == "Off": log_name = create_tcb_api_log( direction="Outbound", @@ -398,7 +441,9 @@ def register_reference_for_sales_order(sales_order_name: str, control_number: st processing_mode="Off", endpoint=endpoint, external_reference=control_number, + related_ref=related_ref, sales_order=sales_order_name, + plot_contract=plot_contract, request_payload=payload, response_payload={"message": "Outbound reference registration skipped (integration disabled or mode Off)."}, ) @@ -415,7 +460,9 @@ def register_reference_for_sales_order(sales_order_name: str, control_number: st processing_mode="Log Only", endpoint=endpoint, external_reference=control_number, + related_ref=related_ref, sales_order=sales_order_name, + plot_contract=plot_contract, request_payload=payload, response_payload={"message": "Outbound Log Only mode — no call sent to TCB."}, ) @@ -467,7 +514,9 @@ def register_reference_for_sales_order(sales_order_name: str, control_number: st tcb_status_code=tcb_status, tcb_message=tcb_message, external_reference=control_number, + related_ref=related_ref, sales_order=sales_order_name, + plot_contract=plot_contract, request_payload=payload, response_payload=parsed_body, error=None if ok else "TCB reference API returned non-success status.", @@ -522,7 +571,9 @@ def register_reference_for_sales_order(sales_order_name: str, control_number: st tcb_status_code=tcb_status, tcb_message=tcb_message, external_reference=control_number, + related_ref=related_ref, sales_order=sales_order_name, + plot_contract=plot_contract, request_payload=payload, response_payload=parsed_body, error=traceback, @@ -1311,6 +1362,7 @@ def create_tcb_api_log( tcb_status_code: int | None = None, tcb_message: str | None = None, external_reference: str | None = None, + related_ref: str | None = None, transaction_id: str | None = None, sales_order: str | None = None, plot_contract: str | None = None, @@ -1346,6 +1398,7 @@ def create_tcb_api_log( "tcb_status_code": cint(tcb_status_code), "tcb_message": (tcb_message or "")[:140], "external_reference": external_reference or "", + "related_ref": related_ref or "", "transaction_id": transaction_id or "", "sales_order": sales_order or "", "plot_contract": plot_contract or "",