CRM Stage Validation

CRM Stage Validation

What was done

Opportunities can no longer be moved forward into a new pipeline stage unless the required information for that stage is complete. If something's missing, you'll see a message telling you exactly which field(s) still need to be filled in, and the Opportunity will stay in its current stage until you've completed them.

This applies to: New, Qualified, Pitched, Won, Lost, and Disqualified. 

Why this matters

  • Cleaner, more consistent data across every Regional Sales Manager's pipeline.
  • More accurate forecasting and reporting, since incomplete Opportunities can no longer slip through stages.
  • Easier coaching, since managers can trust that a "Pitched" Opportunity actually has pricing and next steps recorded.

What's required at each stage

  • New: Customer must be a Treatment Center or Provider, Salesperson, Address (on the linked Customer), Phone, and at least one Tag.
  • Qualified: Contact Name and a scheduled Next Activity.
  • Pitched: Expected Revenue, Pricelist Presented, and a scheduled Next Activity.
  • Won: Expected First Order Date, and a scheduled activity of type Onboarding specifically (not just any activity).
  • Lost: Selecting a Lost Reason (same reasons as before, nothing new here).
  • Disqualified: Selecting a Disqualification Reason from a new, fixed list of 7 reasons. Choosing "Other" requires a short note explaining why.

What's new for you day to day

  • Dragging a card into Disqualified or Lost now pops up a small form asking for the reason before the move completes. This replaces just dragging the card straight over.
  • A few fields are new on the Opportunity form: Expected First Order Date (near Expected Closing), and Disqualification Reason / Note (populated automatically by the popup, not something you'll normally touch directly).
  • Existing Opportunities that were already sitting in a stage before this rolled out are not retroactively affected. You can keep working with them normally. The validation only kicks in the moment you try to move something forward into a new stage.

How to test it

  1. Try creating a brand new Opportunity without filling in Customer, Address, Phone, or Tags. Confirm you get a clear message listing what's missing, and the record won't save until it's complete.
  2. Try dragging an Opportunity into Qualified, Pitched, or Won without its required fields filled in. Confirm the move is blocked with a message naming exactly what's missing.
  3. Fill in the missing fields and try again. Confirm the move now goes through.
  4. Drag an Opportunity into Disqualified. Confirm the popup appears, pick "Other," try submitting with no note, and confirm you're asked for one. Add a note and confirm it goes through.
  5. Drag an Opportunity into Lost. Confirm the popup appears and asks for a Lost Reason as usual.
  6. Try moving an Opportunity backward (for example, from Pitched back to Qualified). Confirm this is never blocked.
  7. Confirm dragging into Appointment Set is never blocked, regardless of what's filled in.


CRM Stage-Gate Validation - Technical Brief

Branch: crm_stage_validation (off main) Module: bista_menmd_si

Summary

Opportunities can no longer advance forward through the pipeline (New -> Qualified -> Pitched -> Won -> Lost/Disqualified) unless the required fields for the destination stage are filled in. Blocked moves show a message naming the missing fields. Existing (pre-project) Opportunities are grandfathered: validation only fires on the write that changes stage_id, never on unrelated edits.

New/changed model fields (crm.lead)

Field Type Purpose
expected_first_order_date Date Required for Won
disqualification_reason_id Many2one to crm.disqualification.reason Set via Disqualify wizard
disqualification_note Text Required only when reason = "Other"

New model

crm.disqualification.reason: a simple name/sequence model, seeded with 7 fixed reasons (including "Other") via data/crm_disqualification_reason_data.xml, noupdate="1". Access: read-only for sales_team.group_sale_salesman, full CRUD for sales_team.group_sale_manager. Menu added under CRM > Configuration > Pipeline, alongside Lost Reasons. It inherits the same admin/manager-only menu visibility, so reps only ever interact with it via the wizard dropdown.

New activity type

mail.activity.type record "Onboarding" (data/mail_activity_type_data.xml), scoped to res_model = 'crm.lead'. Won-stage validation checks for an activity of this specific type, not just any activity, via activity_ids.filtered(lambda a: a.activity_type_id == onboarding_type).

Validation logic (models/crm_lead.py)

  • _get_stage_requirements(): dict mapping a gated stage's XML ID to its required field checks (sentinel strings _has_activity / _has_onboarding_activity handle the activity-based checks).
  • _check_field_value(field_name, vals): checks the incoming write() vals dict first, falls back to the record's current value only if the field isn't part of this write. This matters: a wizard writing disqualification_reason_id and stage_id in the same call needs the reason checked against vals, not the pre-write (still-empty) field value.
  • _check_stage_requirements(new_stage, vals): resolves the target stage's requirements via its ir.model.data XML ID (not name matching, so renaming a stage doesn't silently break validation), raises ValidationError listing missing fields if any.
  • write(): runs the check only when stage_id is in vals and new_stage.sequence > lead.stage_id.sequence (forward-only). This is merged into the existing milestone-stamping write() override. Do not add a second write() method to this class; Python silently lets the second definition win, which caused a real bug during development where validation appeared to do nothing.
  • New-stage requirements (Customer type, Salesperson, Address, Phone, Tag) are enforced in _check_new_lead_requirements(), called explicitly from create(), not via @api.constrains. An earlier version used @api.constrains, which re-validates on every subsequent write to any watched field, permanently blocking edits to old incomplete records. Keep this as an explicit create() call.
  • Address check validates lead.partner_id.street (the linked Customer's address), not a field on the Opportunity itself. crm.lead has no native Address field on this customized form.

Stage XML IDs

The 7 relevant crm.stage records pre-existed in the DB without XML IDs. Assigned via direct SQL (not module data, since these are pre-existing rows, not module-seeded ones):

INSERT INTO ir_model_data (name, module, model, res_id, noupdate) VALUES
  ('crm_stage_qualified', 'bista_menmd_si', 'crm.stage', <id>, true),
  ('crm_stage_pitched', 'bista_menmd_si', 'crm.stage', <id>, true),
  ('crm_stage_won', 'bista_menmd_si', 'crm.stage', <id>, true),
  ('crm_stage_lost', 'bista_menmd_si', 'crm.stage', <id>, true),
  ('crm_stage_disqualified', 'bista_menmd_si', 'crm.stage', <id>, true);

This must be re-run on UAT/production against the actual stage IDs there. Confirm via SELECT id, name FROM crm_stage ORDER BY sequence; first, since the numeric IDs will differ from local dev. Without this, env.ref() calls throughout the validation and wizard code will fail.

Wizards

Two separate transient models/wizards, deliberately not sharing a model or a base class:

  • crm.lead.disqualify: new reason model, "Other" requires a note.
  • crm.lead.lost.gate: wraps the existing, unmodified crm.lost.reason.

Do not add a lead_ids Many2many field with a context-derived default to either wizard. An earlier version had lead_ids = fields.Many2many('crm.lead', default=lambda self: self.env.context.get('active_ids')), which triggered an unwanted blank crm.lead creation as a side effect of resolving that default. Both wizards now read self.env.context.get('active_ids') directly inside their action methods instead.

Kanban drag-and-drop (static/src/js/crm_stage_gate_kanban.js)

Custom KanbanRenderer override (js_class="crm_stage_gate_kanban", applied via an inherited view on crm.crm_case_kanban_view_leads) intercepts sortRecordDrop. Two non-obvious things to preserve:

  1. Both the drop target's group ID and the dragged record's ID arrive as internal Kanban datapoint references (e.g. "datapoint_121"), not real database IDs. The code resolves real IDs via this.props.list.groups (for the target stage) and by walking group.list.records for .resId (for the dragged record). Using parseInt() directly on either datapoint string silently produces NaN/null and causes writes to silently no-op with no error. This was a real bug during development, took a while to trace.
  2. The web.assets_web bundle does not reliably rebuild on a plain container restart during local dev. If JS changes aren't showing up, force it: DELETE FROM ir_attachment WHERE name LIKE 'web.assets_web%'; then restart.

Gated stage IDs are resolved via check_object_reference against the XML IDs above, not hardcoded, so this survives ID differences between environments as long as the ir_model_data insert above has been run.