Skip to content

Commit 3e65616

Browse files
fix(landms): payment-term allocation, sync circular-cancel, lifecycle automation
Payment sync: build PE reference rows per SI payment-term so ERPNext updates each schedule row's paid_amount. Previously only SI outstanding moved — per-row paid stayed 0, so contract advance/completion never fired. Cancellation: back-reference Link fields (SO <-> SI, Plot Application, Plot Contract, Plot Handover) now stored as Data. Frappe only walks Link fields during cancel, so the circular dependency that blocked cancellation is gone. Migration patch rewrites existing rows in place. Lifecycle: Sales Order is auto-created on application-fee payment; TCB registration runs inline for immediate control-number feedback; PEs from TCB are named by transaction_id for audit trace; Plot Handover Draft is created automatically on contract completion. Other: application fee item sourced from LandMS Settings; Plot Master Stock Entry pins expense_account to Land Under Development so plot creation doesn't inherit the item's delivery-side COGS default.
1 parent e44a32a commit 3e65616

19 files changed

Lines changed: 442 additions & 151 deletions

landms/landms/doctype/landms_settings/landms_settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@
9999
"fieldtype": "Section Break",
100100
"label": "Application Fee"
101101
},
102+
{
103+
"fieldname": "application_fee_item",
104+
"fieldtype": "Link",
105+
"label": "Application Fee Item",
106+
"options": "Item",
107+
"reqd": 1,
108+
"description": "Service item used on the application fee Sales Invoice"
109+
},
102110
{
103111
"fieldname": "application_fee_amount",
104112
"fieldtype": "Float",

landms/landms/doctype/plot_application/plot_application.js

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,42 +25,6 @@ frappe.ui.form.on('Plot Application', {
2525
}, __('Actions'));
2626
}
2727

28-
// Create Sales Order — only on Paid applications
29-
if (frm.doc.docstatus === 1 && frm.doc.status === 'Paid' && !frm.doc.sales_order) {
30-
frm.add_custom_button(__('Create Sales Order'), () => {
31-
frappe.call({
32-
method: 'create_sales_order',
33-
doc: frm.doc,
34-
args: { notify: 1 },
35-
freeze: true,
36-
freeze_message: __('Creating Sales Order...'),
37-
callback(r) {
38-
if (!r.exc) frm.reload_doc();
39-
},
40-
});
41-
}, __('Actions'));
42-
}
43-
44-
// Cancel Application — manual cancellation for Submitted or Paid
45-
if (frm.doc.docstatus === 1 && ['Submitted', 'Paid'].includes(frm.doc.status)) {
46-
frm.add_custom_button(__('Cancel Application'), () => {
47-
frappe.confirm(
48-
__('Are you sure you want to cancel this Plot Application? The plot will be released and any linked Sales Order will be cancelled.'),
49-
() => {
50-
frappe.call({
51-
method: 'frappe.client.cancel',
52-
args: { doctype: 'Plot Application', name: frm.doc.name },
53-
freeze: true,
54-
freeze_message: __('Cancelling...'),
55-
callback(r) {
56-
if (!r.exc) frm.reload_doc();
57-
},
58-
});
59-
}
60-
);
61-
}, __('Actions'));
62-
}
63-
6428
// Jump to the linked Sales Order
6529
if (frm.doc.sales_order) {
6630
frm.add_custom_button(__('View Sales Order'), () => {
@@ -148,7 +112,7 @@ function open_fee_payment_dialog(frm) {
148112
reference_no: values.reference_no || '',
149113
},
150114
freeze: true,
151-
freeze_message: __('Recording payment...'),
115+
freeze_message: __('Recording payment & creating Sales Order...'),
152116
callback(r) {
153117
if (!r.exc) {
154118
d.hide();

landms/landms/doctype/plot_application/plot_application.json

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,17 +155,15 @@
155155
},
156156
{
157157
"fieldname": "sales_invoice",
158-
"fieldtype": "Link",
158+
"fieldtype": "Data",
159159
"label": "Sales Invoice",
160-
"options": "Sales Invoice",
161160
"read_only": 1,
162161
"description": "Sales Invoice created for the application fee"
163162
},
164163
{
165164
"fieldname": "payment_entry",
166-
"fieldtype": "Link",
165+
"fieldtype": "Data",
167166
"label": "Payment Entry",
168-
"options": "Payment Entry",
169167
"read_only": 1,
170168
"description": "Payment Entry that settled the application fee SI"
171169
},
@@ -177,9 +175,8 @@
177175
},
178176
{
179177
"fieldname": "sales_order",
180-
"fieldtype": "Link",
178+
"fieldtype": "Data",
181179
"label": "Sales Order",
182-
"options": "Sales Order",
183180
"read_only": 1,
184181
"description": "ERP Sales Order created after application fee payment"
185182
},

landms/landms/doctype/plot_application/plot_application.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ def record_fee_payment(self, payment_date, bank_account=None, reference_no=None)
258258
self._validate_receiving_account(bank_account, settings.company)
259259

260260
# --- Sales Invoice ---
261+
if not settings.application_fee_item:
262+
frappe.throw("Application Fee Item is not configured in LandMS Settings.")
263+
261264
si = frappe.get_doc({
262265
"doctype": "Sales Invoice",
263266
"customer": self.customer,
@@ -266,8 +269,7 @@ def record_fee_payment(self, payment_date, bank_account=None, reference_no=None)
266269
"company": settings.company,
267270
"remarks": f"Application fee for Plot {self.plot} — Application {self.name}",
268271
"items": [{
269-
"item_name": "Application Fee",
270-
"description": f"Plot application fee — {self.name} / Plot {self.plot}",
272+
"item_code": settings.application_fee_item,
271273
"qty": 1,
272274
"rate": fee_amount,
273275
"income_account": settings.application_fee_income_account,
@@ -313,11 +315,12 @@ def record_fee_payment(self, payment_date, bank_account=None, reference_no=None)
313315
frappe.db.set_value("Plot Master", self.plot, "status", "Pending Advance")
314316
self._sync_land_acquisition_summary()
315317

318+
so_name = self.create_sales_order(notify=0)
319+
316320
frappe.msgprint(
317321
f"Application fee of TZS {fee_amount:,.0f} recorded. "
318-
f"Plot {self.plot} is now Pending Advance until {expiry}. "
319-
f"Sales Invoice {si.name} settled via Payment Entry {pe.name}. "
320-
"Next: create the Sales Order and collect the first advance within the validity window.",
322+
f"Sales Order <b>{so_name}</b> created for Plot {self.plot}. "
323+
f"Reservation valid until {expiry}.",
321324
indicator="green",
322325
alert=True,
323326
)

landms/landms/doctype/plot_contract/plot_contract.json

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@
137137
"fieldname": "apply_auto_cancellation",
138138
"fieldtype": "Check",
139139
"label": "Apply Auto-Cancellation",
140-
"default": "1",
141-
"description": "Inherited from Sales Order. Uncheck to allow payment beyond deadline."
140+
"read_only": 1,
141+
"description": "Inherited from Sales Order. Change it on the Sales Order to update this value."
142142
},
143143
{
144144
"fieldname": "control_number",
@@ -264,16 +264,14 @@
264264
},
265265
{
266266
"fieldname": "sales_order",
267-
"fieldtype": "Link",
267+
"fieldtype": "Data",
268268
"label": "Sales Order",
269-
"options": "Sales Order",
270269
"read_only": 1
271270
},
272271
{
273272
"fieldname": "plot_application",
274-
"fieldtype": "Link",
273+
"fieldtype": "Data",
275274
"label": "Plot Application",
276-
"options": "Plot Application",
277275
"read_only": 1
278276
},
279277
{
@@ -282,24 +280,21 @@
282280
},
283281
{
284282
"fieldname": "booking_fee_invoice",
285-
"fieldtype": "Link",
283+
"fieldtype": "Data",
286284
"label": "Plot Sales Invoice",
287-
"options": "Sales Invoice",
288285
"read_only": 1
289286
},
290287
{
291288
"fieldname": "government_fee_entry",
292-
"fieldtype": "Link",
289+
"fieldtype": "Data",
293290
"label": "Government Fee Journal Entry",
294-
"options": "Journal Entry",
295291
"read_only": 1,
296292
"depends_on": "eval:doc.government_fee_entry"
297293
},
298294
{
299295
"fieldname": "forfeiture_entry",
300-
"fieldtype": "Link",
296+
"fieldtype": "Data",
301297
"label": "Forfeiture Journal Entry",
302-
"options": "Journal Entry",
303298
"read_only": 1,
304299
"depends_on": "eval:doc.forfeiture_entry"
305300
},

landms/landms/doctype/plot_contract/plot_contract.py

Lines changed: 88 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ def sync_payment_status(self):
333333
invoice_name = self._get_plot_invoice_name()
334334
if not invoice_name:
335335
self._sync_header_from_sales_order(so_doc)
336+
self._persist_payment_sync_state()
336337
return
337338

338339
si_doc = frappe.get_doc("Sales Invoice", invoice_name)
@@ -346,24 +347,21 @@ def sync_payment_status(self):
346347
if len(self.payment_schedule or []) != len(si_doc.payment_schedule or []) and self.docstatus == 0:
347348
self._rebuild_schedule_from_invoice(si_doc)
348349

350+
self._sync_header_from_sales_order(so_doc, invoice_name=si_doc.name)
351+
self._sync_schedule_rows_from_invoice(si_doc, paid_dates)
352+
self.total_contract_value = flt(si_doc.grand_total)
353+
self.total_paid = total_paid
354+
self.total_outstanding = total_outstanding
355+
self.payment_progress = self._derive_payment_progress(total_paid, total_outstanding, si_doc=si_doc)
356+
self._persist_payment_sync_state()
357+
349358
advance_met = self._is_advance_installment_met(si_doc)
350359

351-
# Only auto-submit the contract once the full advance is covered.
360+
# Only auto-submit once the synced draft state is already safely saved.
352361
if advance_met and self.docstatus == 0:
353362
self.submit()
354363
self.reload()
355364

356-
self._sync_header_from_sales_order(so_doc, invoice_name=si_doc.name)
357-
self._sync_schedule_rows_from_invoice(si_doc, paid_dates)
358-
self.db_set("total_contract_value", flt(si_doc.grand_total), update_modified=False)
359-
self.db_set("total_paid", total_paid, update_modified=False)
360-
self.db_set("total_outstanding", total_outstanding, update_modified=False)
361-
self.db_set(
362-
"payment_progress",
363-
self._derive_payment_progress(total_paid, total_outstanding, si_doc=si_doc),
364-
update_modified=False,
365-
)
366-
367365
if so_doc.get("plot_application"):
368366
app_status = frappe.db.get_value("Plot Application", so_doc.plot_application, "status")
369367
if advance_met and app_status == "Paid":
@@ -372,38 +370,47 @@ def sync_payment_status(self):
372370
so_doc.plot_application,
373371
"status",
374372
"Converted",
373+
update_modified=True,
375374
)
375+
frappe.clear_document_cache("Plot Application", so_doc.plot_application)
376376

377377
if total_outstanding <= 0 and self.docstatus == 1:
378-
self.db_set("contract_status", "Completed", update_modified=False)
378+
self.contract_status = "Completed"
379379
plot_status = frappe.db.get_value("Plot Master", self.plot, "status")
380380
if plot_status not in ("Delivered", "Title Closed"):
381-
frappe.db.set_value("Plot Master", self.plot, "status", "Ready for Handover")
381+
frappe.db.set_value("Plot Master", self.plot, "status", "Ready for Handover", update_modified=True)
382+
frappe.clear_document_cache("Plot Master", self.plot)
382383
self._sync_land_acquisition_summary()
383384

385+
# Persist the submitted contract state before posting linked entries.
386+
self._persist_payment_sync_state()
384387
settings = frappe.get_single("LandMS Settings")
385388
self.reload()
386389
je_name = self._post_completion_entries(settings)
390+
handover_name = self._ensure_handover_draft()
387391

388392
msg = f"Contract fully paid. Plot {self.plot} marked as Ready for Handover."
389393
if je_name:
390394
msg += f" Government fee posted — Journal Entry: {je_name}."
395+
if handover_name:
396+
msg += f" Plot Handover <b>{handover_name}</b> created (Draft) — fill the handover parties and submit."
391397
frappe.msgprint(msg, indicator="green", alert=True)
398+
return # already saved above
392399

393400
elif advance_met and self.docstatus == 1:
394-
self.db_set("contract_status", "Ongoing", update_modified=False)
401+
self.contract_status = "Ongoing"
395402
if frappe.db.get_value("Plot Master", self.plot, "status") == "Pending Advance":
396-
frappe.db.set_value("Plot Master", self.plot, "status", "Reserved")
403+
frappe.db.set_value("Plot Master", self.plot, "status", "Reserved", update_modified=True)
404+
frappe.clear_document_cache("Plot Master", self.plot)
397405
self._sync_land_acquisition_summary()
398406

407+
self._persist_payment_sync_state()
408+
399409
def _sync_header_from_sales_order(self, so_doc, *, invoice_name: str | None = None):
400-
updates = {
401-
"plot_application": so_doc.get("plot_application") or "",
402-
"control_number": so_doc.get("control_number") or "",
403-
"booking_fee_invoice": invoice_name or so_doc.get("plot_sales_invoice") or "",
404-
"payment_deadline": so_doc.get("payment_deadline"),
405-
}
406-
frappe.db.set_value("Plot Contract", self.name, updates, update_modified=False)
410+
self.plot_application = so_doc.get("plot_application") or ""
411+
self.control_number = so_doc.get("control_number") or ""
412+
self.booking_fee_invoice = invoice_name or so_doc.get("plot_sales_invoice") or ""
413+
self.payment_deadline = so_doc.get("payment_deadline")
407414

408415
def _rebuild_schedule_from_invoice(self, invoice):
409416
# Compute expected from the original grand_total * invoice_portion,
@@ -450,21 +457,39 @@ def _sync_schedule_rows_from_invoice(self, invoice, paid_dates):
450457
status = self._derive_installment_status(source.due_date, expected, outstanding, today_date=today_date)
451458
paid_date = paid_dates.get(idx) if outstanding <= 0 else None
452459

453-
frappe.db.set_value(
454-
"Plot Contract Payment",
455-
target.name,
456-
{
457-
"description": source.description or self._default_installment_label(idx),
458-
"due_date": source.due_date,
459-
"expected_amount": expected,
460-
"paid_amount": paid_amount,
461-
"outstanding_amount": outstanding,
462-
"paid_date": paid_date,
463-
"sales_invoice": invoice.name,
464-
"status": status,
465-
},
466-
update_modified=False,
467-
)
460+
target.description = source.description or self._default_installment_label(idx)
461+
target.due_date = source.due_date
462+
target.expected_amount = expected
463+
target.paid_amount = paid_amount
464+
target.outstanding_amount = outstanding
465+
target.paid_date = paid_date
466+
target.sales_invoice = invoice.name
467+
target.status = status
468+
if self.docstatus == 1 and target.name:
469+
target.db_update()
470+
471+
def _persist_payment_sync_state(self):
472+
if self.docstatus == 0:
473+
self.save(ignore_permissions=True)
474+
return
475+
476+
frappe.db.set_value(
477+
"Plot Contract",
478+
self.name,
479+
{
480+
"plot_application": self.plot_application or "",
481+
"control_number": self.control_number or "",
482+
"booking_fee_invoice": self.booking_fee_invoice or "",
483+
"payment_deadline": self.payment_deadline,
484+
"total_contract_value": flt(self.total_contract_value),
485+
"total_paid": flt(self.total_paid),
486+
"total_outstanding": flt(self.total_outstanding),
487+
"payment_progress": self.payment_progress or "",
488+
"contract_status": self.contract_status or "Draft",
489+
},
490+
update_modified=True,
491+
)
492+
frappe.clear_document_cache("Plot Contract", self.name)
468493

469494
def _derive_installment_status(self, due_date, expected, outstanding, *, today_date=None):
470495
today_date = today_date or getdate(today())
@@ -585,6 +610,31 @@ def _post_completion_entries(self, settings):
585610
self.db_set("government_fee_entry", je.name)
586611
return je.name
587612

613+
def _ensure_handover_draft(self):
614+
existing = frappe.db.get_value(
615+
"Plot Handover",
616+
{"contract": self.name, "docstatus": ["!=", 2]},
617+
"name",
618+
)
619+
if existing:
620+
return existing
621+
622+
handover = frappe.get_doc({
623+
"doctype": "Plot Handover",
624+
"contract": self.name,
625+
"handover_date": today(),
626+
"customer": self.customer,
627+
"plot": self.plot,
628+
"acquisition_name": self.get("acquisition_name") or "",
629+
"land_acquisition": self.get("land_acquisition") or "",
630+
"contract_date": self.get("contract_date"),
631+
"selling_price": flt(self.selling_price),
632+
})
633+
handover.flags.ignore_permissions = True
634+
handover.flags.ignore_mandatory = True
635+
handover.insert()
636+
return handover.name
637+
588638
def _post_termination_journal_entry(self, settings):
589639
if self.forfeiture_entry:
590640
return None

landms/landms/doctype/plot_handover/plot_handover.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,8 @@
3939
},
4040
{
4141
"fieldname": "delivery_note",
42-
"fieldtype": "Link",
42+
"fieldtype": "Data",
4343
"label": "Delivery Note",
44-
"options": "Delivery Note",
4544
"read_only": 1,
4645
"description": "Created automatically on submit to reduce serialized plot stock"
4746
},

0 commit comments

Comments
 (0)