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(`
+
+
+
+
+ | Date |
+ Journal Entry |
+ Remarks |
+ Amount (TZS) |
+
+
+ ${body}
+
+
+ | 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 "",