<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohit</title>
    <description>The latest articles on DEV Community by Mohit (@mohit_777aded202b4d39397d).</description>
    <link>https://dev.to/mohit_777aded202b4d39397d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4042057%2F03dbb76b-eda4-437e-83a2-be4b3263f556.png</url>
      <title>DEV Community: Mohit</title>
      <link>https://dev.to/mohit_777aded202b4d39397d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohit_777aded202b4d39397d"/>
    <language>en</language>
    <item>
      <title>Where Standard Odoo Ends: When Construction Companies Should Consider Customization</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:34:52 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/where-standard-odoo-ends-when-construction-companies-should-consider-customization-30c7</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/where-standard-odoo-ends-when-construction-companies-should-consider-customization-30c7</guid>
      <description>&lt;p&gt;"Can we just configure that?"&lt;/p&gt;

&lt;p&gt;It gets asked in every scoping session, usually hopefully, and it usually gets answered with a feeling. It deserves better, because the question has a technically checkable answer.&lt;/p&gt;

&lt;p&gt;Standard Odoo covers more construction requirements than most people assume. It also stops covering them at identifiable points, and those points are properties of the data model rather than judgements about difficulty. Below are five signals that a requirement has crossed the line — each one a structural fact you can check before anyone estimates anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: you need the history of a value, not its current state
&lt;/h2&gt;

&lt;p&gt;This is the one that catches most construction projects, and percent complete is where it shows up.&lt;/p&gt;

&lt;p&gt;A field like this looks reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConstructionContract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;percent_complete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_compute_percent_complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@api.depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_ids.completed_qty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_ids.contract_qty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_compute_percent_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract_qty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed_qty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;percent_complete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two problems, and they are both structural rather than stylistic.&lt;/p&gt;

&lt;p&gt;First, &lt;a href="https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html" rel="noopener noreferrer"&gt;computed fields are not stored by default&lt;/a&gt; — they are calculated and returned when requested. Search and grouping are unavailable on them unless you write a custom &lt;code&gt;search&lt;/code&gt; method. So the moment someone asks to group jobs by completion band, this field cannot do it.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;store=True&lt;/code&gt; fixes searching and grouping. It does not fix the second problem, which is worse: a stored field holds one value, the current one. Construction needs to know what percent complete &lt;em&gt;was&lt;/em&gt; at the end of each billing period, because that is what was invoiced against and what the next invoice is measured from. Overwrite it monthly and the history is gone.&lt;/p&gt;

&lt;p&gt;The tell is easy to check. If a stakeholder ever says "as at" — as at last month, as at the previous application, as at year end — a scalar field is the wrong shape regardless of how it is computed. You need a record per period:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConstructionProgressLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.progress.line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;_order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;period_end desc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;contract_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Many2one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ondelete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cascade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;period_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;percent_complete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;recorded_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Many2one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;res.users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a new model. It is not configuration, and no amount of Studio gets you there — because the requirement is temporal, and the standard model has no time dimension on that value.&lt;/p&gt;

&lt;p&gt;One related gotcha while you are in this territory: computed fields run with &lt;code&gt;compute_sudo=True&lt;/code&gt; by default, meaning the computation executes with superuser privileges regardless of who is asking. If your computed value aggregates records the requesting user is not supposed to see, that is worth thinking about before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: the fact belongs to the relationship, not to either record
&lt;/h2&gt;

&lt;p&gt;A subcontractor works on several of your jobs. A job uses several subcontractors. The obvious modelling instinct is a many-to-many, and Odoo will happily give you one.&lt;/p&gt;

&lt;p&gt;Then the requirements arrive. What is this sub's scope on &lt;em&gt;this&lt;/em&gt; job? What is the awarded value on &lt;em&gt;this&lt;/em&gt; job? What retention percentage applies here, which is different from the one on the job across town? When does their compliance for this specific site expire?&lt;/p&gt;

&lt;p&gt;None of those belong to the subcontractor. None belong to the job. They belong to the pairing — and a many-to-many produces a bare join table with two foreign key columns and nowhere to put them.&lt;/p&gt;

&lt;p&gt;The moment a relationship needs attributes, you need an explicit model for the relationship itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConstructionSubcontract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.subcontract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;_description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subcontractor engagement on a specific job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;job_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Many2one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ondelete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cascade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;partner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Many2one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;res.partner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subcontractor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;awarded_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Monetary&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;retention_percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;compliance_expiry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;currency_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Many2one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;res.currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is worth catching early because retrofitting it is genuinely painful. Data already entered against a many-to-many has no attributes to migrate, so somebody reconstructs them from contracts and email, by hand, for every live job.&lt;/p&gt;

&lt;p&gt;The general test: if you catch yourself wanting to write a field name like &lt;code&gt;retention_percent_for_this_job_only&lt;/code&gt;, the relationship needs to be a record.&lt;/p&gt;

&lt;p&gt;If you want the functional context behind these requirements rather than the modelling argument, there is &lt;a href="https://www.mastersoftwaresolutions.com/odoo-erp-for-the-construction-industry/" rel="noopener noreferrer"&gt;a non-technical overview of Odoo for contractors&lt;/a&gt; that covers the workflow side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: you need different state semantics than the ones shipped
&lt;/h2&gt;

&lt;p&gt;Odoo's Project app looks like an obvious home for construction work, and for task management it often is. The boundary here is specific and easy to miss.&lt;/p&gt;

&lt;p&gt;Task &lt;em&gt;stages&lt;/em&gt; are configurable — they are project-specific and can be shared across projects that follow the same workflow. Task &lt;em&gt;statuses&lt;/em&gt; are not. Odoo ships &lt;a href="https://www.odoo.com/documentation/19.0/applications/services/project/tasks/task_stages_statuses.html" rel="noopener noreferrer"&gt;five fixed statuses&lt;/a&gt; — In Progress, Changes Requested, Approved, Canceled and Done — and they cannot be customised. There is also behaviour attached: Changes Requested and Approved clear automatically when a task moves to another Kanban stage, reverting to In Progress, while Done and Canceled persist.&lt;/p&gt;

&lt;p&gt;That is fine for tasks. It is not a contractual state machine, and construction has several of those — a subcontract that moves through awarded, mobilised, in progress, complete and closed out, with rules about which transitions are legal and what each one triggers.&lt;/p&gt;

&lt;p&gt;Trying to bend task status into that shape is the classic mistake. The status field will keep resetting itself underneath you because it was designed to, and you will end up writing overrides that fight the framework at every stage change.&lt;/p&gt;

&lt;p&gt;Model your own state field on your own model instead, with explicit transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConstructionSubcontract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_inherit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.subcontract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Selection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awarded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Awarded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mobilised&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mobilised&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;In Progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Closed Out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tracking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;action_award&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Only draft subcontracts can be awarded.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compliance_expiry&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compliance_expiry&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cannot award: subcontractor compliance is missing or expired.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awarded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave the shipped task status alone and let it do the job it was built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 4: the rule has to hold at the database, not in the form
&lt;/h2&gt;

&lt;p&gt;Odoo gives you two places to enforce a rule, and the choice matters more in construction than in most domains because the data outlives the people who entered it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@api.constrains&lt;/code&gt; runs Python validation after fields are written. It is flexible, it can look at related records, and it produces good error messages. It is also bypassable — direct SQL, a bad migration script, or a code path that writes with the constraint deferred will all go straight past it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_sql_constraints&lt;/code&gt; pushes the rule into PostgreSQL, where nothing gets past it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConstructionContract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_inherit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;construction.contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;_sql_constraints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_number_uniq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unique(company_id, job_number)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Job number must be unique within the company.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retention_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHECK(retention_percent &amp;gt;= 0 AND retention_percent &amp;lt;= 100)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retention percentage must be between 0 and 100.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the database for anything that would corrupt your reporting if it were ever violated — uniqueness of job numbers, non-negative quantities, percentages within range. Use Python for rules that need context, like the compliance check in the example above.&lt;/p&gt;

&lt;p&gt;If a requirement can only be expressed as a database constraint, that is a customization by definition. There is no configuration screen for a CHECK constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 5: the number has to be reconstructable, not just current
&lt;/h2&gt;

&lt;p&gt;This is signal 1 generalised, and it is the one most worth internalising because it catches requirements before they are written down.&lt;/p&gt;

&lt;p&gt;Ask of any number the business cares about: can we reproduce what this was on an arbitrary past date? If the answer is no and someone will eventually need it, the model is missing a dimension.&lt;/p&gt;

&lt;p&gt;Cost committed as at each month end. Contract value before and after each variation. Which subcontractors were compliant on the day of an incident. Every one of those is a question the business will ask exactly once, at the worst possible moment, and a current-state model cannot answer any of them.&lt;/p&gt;

&lt;p&gt;Odoo gives you some of this free. Fields with &lt;code&gt;tracking=True&lt;/code&gt; write to the chatter, which is an audit trail and is fine for "who changed this". It is not a reporting structure — you cannot group or aggregate across it usefully. If the historical value needs to appear in a report, it needs to be a record, not a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where standard genuinely holds
&lt;/h2&gt;

&lt;p&gt;Worth saying plainly, because the signals above are easy to over-apply.&lt;/p&gt;

&lt;p&gt;Multi-dimensional cost analysis, document management, purchasing workflows, approval routing, resource scheduling and most reporting are configuration problems in Odoo, not code problems. Reaching for a custom model when a configuration option exists is a decision to maintain something forever that Odoo would have maintained for you.&lt;/p&gt;

&lt;p&gt;Two framework limits worth knowing before you assume something is impossible, though. Related fields cannot chain through Many2many or One2many in their dependency paths — that limitation catches people who try to reach across two hops and conclude the framework cannot do it, when a stored computed field with an explicit &lt;code&gt;@api.depends&lt;/code&gt; usually can. And a non-stored computed field that "cannot be searched" often just needs &lt;code&gt;store=True&lt;/code&gt; plus a correct depends list rather than a new model.&lt;/p&gt;

&lt;p&gt;Check both before escalating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run before anyone opens an editor
&lt;/h2&gt;

&lt;p&gt;For each requirement, answer four questions.&lt;/p&gt;

&lt;p&gt;Does it need a value as at a past date? Does it need attributes on a relationship rather than on a record? Does it need a state machine other than the one shipped? Does it need a rule enforced below the application layer?&lt;/p&gt;

&lt;p&gt;Any yes means you have left standard behind, and that is fine — it just means the item belongs in a build scope with an owner and a maintenance plan rather than in a configuration checklist. Scoping &lt;a href="https://www.mastersoftwaresolutions.com/odoo-customization-services/" rel="noopener noreferrer"&gt;Odoo ERP customization&lt;/a&gt; honestly at this stage is considerably cheaper than discovering the boundary halfway through a rollout, when the data is already in and the retrofit is a migration.&lt;/p&gt;

&lt;p&gt;All four answers no, and someone should go and find the configuration option, because it is probably there.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How Consultants Map Existing Workflows to Odoo (Down to the Model Names)</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:40:02 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/how-consultants-map-existing-workflows-to-odoo-down-to-the-model-names-64n</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/how-consultants-map-existing-workflows-to-odoo-down-to-the-model-names-64n</guid>
      <description>&lt;p&gt;Most workflow mapping produces a diagram. Diagrams are the wrong deliverable.&lt;/p&gt;

&lt;p&gt;What an implementation actually needs is a crosswalk: every step of your current process, sitting next to the Odoo model and field that will carry it, with the steps that have no home flagged and classified. That artefact is testable. A swimlane diagram is not — you cannot run a query against it to find out whether it is true.&lt;/p&gt;

&lt;p&gt;Here is the method, including the checks that catch a bad mapping before it reaches a statement of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: capture the process as events, not boxes
&lt;/h2&gt;

&lt;p&gt;Do not start with a workshop asking people to describe their process. You will get the official version.&lt;/p&gt;

&lt;p&gt;Start by picking one real document and tracing it backwards. "Show me the last order you shipped" — then follow it: who typed it, what they typed it into, what triggered the next person, what they printed, where the paper went, what got re-keyed. For each step, record five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;trigger&lt;/strong&gt; (a time, an email, a phone call, someone walking over)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;actor&lt;/strong&gt; (a role, not a name)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;system of record&lt;/strong&gt; at that moment&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;decision&lt;/strong&gt; being made, if any&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;artefact&lt;/strong&gt; produced (a document, a status change, a note in a spreadsheet)
You want three to five traces per document type, ideally including one that went wrong. The exception traces are where the real requirements live. A process map built only from happy paths maps a business that doesn't exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip BPMN at this stage. Formal notation is excellent for communicating an agreed process and useless for discovering a disputed one, and every hour spent on notation is an hour not spent finding the fourth exception nobody mentioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: name the Odoo object for every step
&lt;/h2&gt;

&lt;p&gt;This is the actual mapping work, and it is unglamorous. Each captured step gets a row.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Current process step&lt;/th&gt;
&lt;th&gt;Odoo model&lt;/th&gt;
&lt;th&gt;Fields that carry the meaning&lt;/th&gt;
&lt;th&gt;What usually goes wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer and their delivery sites&lt;/td&gt;
&lt;td&gt;&lt;code&gt;res.partner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;parent_id&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;delivery&lt;/code&gt; / &lt;code&gt;invoice&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Sites modelled as separate customers, which breaks consolidated credit and statements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quotation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sale.order&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt; &lt;code&gt;draft&lt;/code&gt; → &lt;code&gt;sent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;state&lt;/code&gt;, &lt;code&gt;validity_date&lt;/code&gt;, &lt;code&gt;pricelist_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two legacy statuses both land on &lt;code&gt;sent&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirmed order&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sale.order&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt; = &lt;code&gt;sale&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;date_order&lt;/code&gt;, &lt;code&gt;commitment_date&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;"Confirmed" and "released to warehouse" are one state here, not two&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pick, pack, dispatch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stock.picking&lt;/code&gt; + &lt;code&gt;stock.move&lt;/code&gt; / &lt;code&gt;stock.move.line&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;picking_type_id&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;lot_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Assumed to need code; multi-step routes are configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stock available to promise&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stock.quant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;reserved_quantity&lt;/code&gt;, &lt;code&gt;location_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Legacy "available" rarely equals &lt;code&gt;quantity&lt;/code&gt; − &lt;code&gt;reserved_quantity&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;account.move&lt;/code&gt;, &lt;code&gt;move_type&lt;/code&gt; = &lt;code&gt;out_invoice&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invoice_date&lt;/code&gt;, &lt;code&gt;invoice_payment_term_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Proforma has no native state and gets faked badly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume or contract pricing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;product.pricelist.item&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;min_quantity&lt;/code&gt;, &lt;code&gt;compute_price&lt;/code&gt;, &lt;code&gt;date_start&lt;/code&gt; / &lt;code&gt;date_end&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Quoted as custom development when it is a pricelist rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works order&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mrp.production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bom_id&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;qty_produced&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Byproducts and scrap left unmapped until go-live&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules make this table do real work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If two current statuses map to the same model and state, you have lost a distinction.&lt;/strong&gt; Decide now whether it comes back as a field, a saved filter, or not at all. Discovering it during user acceptance testing is how you end up with a custom field called &lt;code&gt;x_status_2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If one current step maps to two Odoo models, your step was hiding a handoff.&lt;/strong&gt; That handoff is almost always where the process actually fails today. Split the row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a step maps to nothing, it goes to Step 3.&lt;/strong&gt; Do not force it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: classify every gap into one of four buckets
&lt;/h2&gt;

&lt;p&gt;The value of the mapping is not the rows that match. It is the disciplined sorting of the rows that don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 1 — configuration.&lt;/strong&gt; Exists in standard Odoo, needs setting up: multi-step routes, operation types, warehouses, pricelist items, payment terms, fiscal positions, units of measure. No code, no Studio, no cost beyond consulting time. Most first-draft gap lists are 60–70% this bucket once someone experienced reads them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 2 — no-code automation.&lt;/strong&gt; Odoo's &lt;a href="https://www.odoo.com/documentation/19.0/applications/studio/automated_actions.html" rel="noopener noreferrer"&gt;automation rules&lt;/a&gt; cover a surprising amount: trigger on create or write or a time condition, then update a field, send a message, or run a small server action. Combined with Studio computed fields, this handles most "we need the system to notice X and do Y" requirements. Note the licensing constraint — Studio sits on the Custom plan, not Standard, so a business budgeted at Standard has no bucket 2 at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 3 — code.&lt;/strong&gt; A custom module: &lt;code&gt;_inherit = 'sale.order'&lt;/code&gt;, new fields under a consistent prefix, an overridden method, tests. This is the bucket that scales your invoice and your upgrade risk, because every item in it must be re-tested on every version bump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 4 — not in Odoo.&lt;/strong&gt; Belongs in another system, or is a habit that dies with the migration. Write down which, and who agreed.&lt;/p&gt;

&lt;p&gt;The discipline that matters: nothing is allowed into bucket 3 until someone has genuinely attempted it in buckets 1 and 2 and failed. In mappings we have reviewed, the single most common error is a requirement written as custom development because the person capturing it did not know that standard functionality already covered it — a pricelist rule quoted as a module, a multi-step delivery route quoted as a workflow engine.&lt;/p&gt;

&lt;p&gt;Whether the build then runs in-house or through an &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;Odoo implementation company&lt;/a&gt;, insist the bucket-3 list is signed before anyone quotes a fixed price. The count of items in that bucket, not the number of apps you are switching on, is what determines the number on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: prove the mapping against real data
&lt;/h2&gt;

&lt;p&gt;A mapping that has not been tested against your own records is a hypothesis. Two cheap checks turn it into a finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load a real slice with stable identifiers.&lt;/strong&gt; Import a few thousand real records using an &lt;code&gt;id&lt;/code&gt; column carrying your legacy key as an &lt;a href="https://www.odoo.com/documentation/19.0/applications/essentials/export_import_data.html" rel="noopener noreferrer"&gt;External ID&lt;/a&gt;. Re-running the import then updates rather than duplicating, which means you can iterate the mapping instead of rebuilding the database each time you learn something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run the crosswalk check.&lt;/strong&gt; Put the legacy status on the record as a temporary field, then count the pairs. In SQL, against a copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;x_legacy_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sale_order&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;x_legacy_status&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or through the ORM, which works without database access. Odoo 19 documents an &lt;a href="https://www.odoo.com/documentation/19.0/developer/reference/external_rpc_api.html" rel="noopener noreferrer"&gt;external RPC API&lt;/a&gt; alongside a separate JSON-2 API; the long-standing XML-RPC path still reads cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;xmlrpc.client&lt;/span&gt;

&lt;span class="n"&gt;common&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xmlrpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServerProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/xmlrpc/2/common&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xmlrpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServerProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/xmlrpc/2/object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_kw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sale.order&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;read_group&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[[(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_legacy_status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;!=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)]],&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;groupby&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_legacy_status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lazy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the output for two failure signatures. A legacy status spread across several Odoo states means your mapping rule is ambiguous. Two legacy statuses sharing one Odoo state means a distinction is about to disappear silently. Both are decisions somebody has to make; the query only tells you they exist. External API access also sits on the Custom plan, which is worth knowing before you promise this check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the finished mapping document contains
&lt;/h2&gt;

&lt;p&gt;Not a diagram. Six things: the crosswalk table, the four bucket lists with an owner against each item, the traces you captured including the exceptions, the list of legacy statuses being deliberately dropped, the crosswalk query output, and a named business person's sign-off per document type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this method falls down
&lt;/h2&gt;

&lt;p&gt;It goes stale. A mapping is accurate on the day it is signed and drifts from then on, so if the project stalls for a quarter the exception traces need re-running rather than trusting.&lt;/p&gt;

&lt;p&gt;It also invites over-investment. There is a genuine failure mode where three weeks disappear into beautifully complete documentation that nobody opens during the build, and the honest answer is that for a small single-company deployment, one afternoon of tracing per document type is enough.&lt;/p&gt;

&lt;p&gt;And mapping is descriptive, not evaluative. It tells you where each current step lands in Odoo. It cannot tell you that a step should be deleted rather than migrated — that judgement comes from the people doing the work, which is the argument for having them in the room rather than sending them a diagram to approve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one document
&lt;/h2&gt;

&lt;p&gt;Pick your highest-volume document type. Trace three real instances end to end, one of which went wrong. Write the crosswalk rows. Then sort the gaps into the four buckets and count bucket 3.&lt;/p&gt;

&lt;p&gt;That number is your project. Everything else is scheduling.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>software</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>The Odoo Requirements Checklist That Decides How Much Custom Code You Write</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Fri, 31 Jul 2026 07:43:04 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/the-odoo-requirements-checklist-that-decides-how-much-custom-code-you-write-5dbn</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/the-odoo-requirements-checklist-that-decides-how-much-custom-code-you-write-5dbn</guid>
      <description>&lt;p&gt;Requirements gathering on ERP projects gets treated as a business-analyst chore that happens before the interesting work starts. It isn't. It's the phase that determines how many custom modules you end up maintaining for the next six years, and most of the questions that matter are questions only someone who has read the Odoo source would think to ask.&lt;/p&gt;

&lt;p&gt;This is the checklist I'd want filled in before I opened an editor. It is deliberately not a list of departments to interview.&lt;/p&gt;

&lt;h2&gt;1. Map the client's nouns to models before anything else&lt;/h2&gt;

&lt;p&gt;Every business has five or six words that appear in every sentence they say. "Job." "Load." "Batch." "Run." "Consignment." Write those down on day one and put an Odoo model next to each.&lt;/p&gt;

&lt;p&gt;There are three outcomes, and you need to know which one you're in before anyone estimates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Outcome&lt;/th&gt;
      &lt;th&gt;What it looks like&lt;/th&gt;
      &lt;th&gt;Implementation&lt;/th&gt;
      &lt;th&gt;Realistic cost&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Maps cleanly&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Their "consignment" is a &lt;code&gt;stock.picking&lt;/code&gt; with a different label on it&lt;/td&gt;
      &lt;td&gt;Configuration and terminology only&lt;/td&gt;
      &lt;td&gt;Hours&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Maps with extra fields&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Their "job" is a &lt;code&gt;project.task&lt;/code&gt; plus four fields and an extra state&lt;/td&gt;
      &lt;td&gt;
&lt;code&gt;_inherit&lt;/code&gt; plus a view extension&lt;/td&gt;
      &lt;td&gt;An afternoon to a few days&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Doesn't map&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Their "run" is a scheduling entity with its own lifecycle, approvals and reporting&lt;/td&gt;
      &lt;td&gt;New model, views, security rules, reports&lt;/td&gt;
      &lt;td&gt;Scope it as its own work package&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The failure mode is silent: a business analyst hears "job", nods, and writes "the system will track jobs." Six weeks later someone discovers that a job spans three sales orders, two warehouses, and can be reopened after invoicing. Ask what the noun &lt;em&gt;is&lt;/em&gt;, structurally, and ask what it can be linked to. Cardinality is a requirement, not an implementation detail.&lt;/p&gt;

&lt;h2&gt;2. Get the numbers, not the adjectives&lt;/h2&gt;

&lt;p&gt;"High volume" means nothing. Ask for counts, because Odoo's defaults hold up beautifully at one scale and stop being pleasant at another. Each of these numbers changes a decision you cannot cheaply reverse later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Ask for&lt;/th&gt;
      &lt;th&gt;What it decides&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Orders per day, lines per order&lt;/td&gt;
      &lt;td&gt;Whether a stock list view is a workable UI or the picking team needs a purpose-built screen&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Deliveries per day, invoices per month&lt;/td&gt;
      &lt;td&gt;Whether document generation runs inline or has to be batched and queued&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Active products and partners&lt;/td&gt;
      &lt;td&gt;Whether a computed field can stay non-stored, or has to be stored and indexed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Concurrent users at peak&lt;/td&gt;
      &lt;td&gt;Worker count, hosting shape, and whether long operations need to move off the request cycle&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Peak versus average, by month&lt;/td&gt;
      &lt;td&gt;Everything above, recalculated for the month that actually matters&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wholesaler doing 40 orders a day for eleven months and 900 a day in December has a December problem, not an average problem. Design for December.&lt;/p&gt;

&lt;h2&gt;3. Collect every artefact that leaves the building&lt;/h2&gt;

&lt;p&gt;Not a description of it. The actual file.&lt;/p&gt;

&lt;p&gt;Invoices, credit notes, delivery notes, pick lists, pallet labels, customer statements, EDI payloads, remittance advice, the weekly CSV that goes to the 3PL, the order confirmation email. Each one is a QWeb template, a report, or an export routine, and each one has details — a legal footer, a barcode symbology, a column order the customer's system parses positionally — that nobody mentions until UAT.&lt;/p&gt;

&lt;p&gt;Ask two follow-ups per artefact: who receives it, and what happens if it's wrong. The second answer tells you whether it needs an approval step.&lt;/p&gt;

&lt;p&gt;While you're here, ask about numbering. Do invoice numbers have to continue from the legacy system's sequence? Does the jurisdiction require gapless numbering? &lt;code&gt;ir.sequence&lt;/code&gt; handles this easily if you know before go-live and awkwardly if you find out after the first invoice is posted.&lt;/p&gt;

&lt;h2&gt;4. Nail product identity early, because it's expensive to change&lt;/h2&gt;

&lt;p&gt;This one question reshapes the whole data model: is "500ml bottle, blue cap" a variant of one product template, or its own product?&lt;/p&gt;

&lt;p&gt;Get it wrong and you're not refactoring — you're re-importing, and every historical transaction, report, and price list built on the old structure comes with you. Establish the template/variant split, the attribute list, and whether attributes affect price, cost, barcode, or packaging.&lt;/p&gt;

&lt;p&gt;Then units of measure, which quietly break more implementations than variants do. Ask what they buy in, stock in, sell in, and report in, and get the conversion factors in writing. Fixed factors are fine. Variable ones — catch weight, meat and produce, anything sold by nominal case but invoiced by actual kilos — are a genuine design problem and need to be surfaced in week one, not month four.&lt;/p&gt;

&lt;p&gt;Finally, traceability: lots, serials, or neither, and whether expiry dates or shelf life drive picking order. That decision propagates into every stock move.&lt;/p&gt;

&lt;h2&gt;5. Write the access model down as rules, not as job titles&lt;/h2&gt;

&lt;p&gt;"Sales reps shouldn't see cost prices" is a sentence. &lt;code&gt;ir.rule&lt;/code&gt; records and field-level group restrictions are the implementation, and translating between the two surfaces contradictions fast.&lt;/p&gt;

&lt;p&gt;Work through it as &lt;a href="https://www.odoo.com/documentation/19.0/applications/general/users/access_rights.html" rel="noopener noreferrer"&gt;access rights and record rules&lt;/a&gt;: who can read, write, create, and delete each of the ten or so models that matter, and which records within those models. Then check the exceptions — there is always a regional manager who needs cross-territory read access, and there is always someone who has to approve their own requisition when a colleague is on leave.&lt;/p&gt;

&lt;p&gt;Ask the multi-company question explicitly and early: is this one legal entity with several branches, or several &lt;code&gt;res.company&lt;/code&gt; records with intercompany transactions between them? Those are different projects with different budgets, and clients use the word "company" for both. If it's genuinely multi-company and multi-country, the &lt;a href="https://www.odoo.com/documentation/19.0/applications/finance/fiscal_localizations.html" rel="noopener noreferrer"&gt;fiscal localization&lt;/a&gt; packages involved need naming now, because they bring their own chart of accounts, tax logic, and statutory reports.&lt;/p&gt;

&lt;h2&gt;6. Turn each integration into a contract before the sprint&lt;/h2&gt;

&lt;p&gt;Every external system needs six answers, and "failure behaviour" is the one nobody volunteers. Capture it in something structured you can hand to whoever builds it:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: 3PL despatch feed
  direction: odoo -&amp;gt; external
  trigger: stock.picking state changes to done
  transport: SFTP drop, CSV, one file per picking
  payload_owner: 3PL integrations team (external)
  idempotency: filename includes picking name; re-send overwrites
  on_failure:
    retry: 3 attempts, 15 min apart
    then: flag picking, notify #ops-alerts, do NOT block invoicing
  volume: ~300 files/day, peak 900&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;on_failure&lt;/code&gt; block is the part that gets designed at 2am during go-live if you skip it here. Decide in advance whether a downstream outage should queue, retry, drop, or halt the upstream process — that is a business decision with money attached, not a technical default.&lt;/p&gt;

&lt;p&gt;Name the human owner on the other side too, and confirm they have capacity in your window. External integration owners run on their own release calendar, and that mismatch slips more go-live dates than anything in your own backlog.&lt;/p&gt;

&lt;h2&gt;7. Define done as real transactions, not as a feature list&lt;/h2&gt;

&lt;p&gt;Acceptance criteria written as "the system shall support batch invoicing" are unfalsifiable. Acceptance criteria written as "Priya processes these twelve real orders from quotation to paid invoice, unaided" are testable, and they surface the exceptions the requirements phase missed.&lt;/p&gt;

&lt;p&gt;Pair that with an explicit data migration scope: which historical data actually crosses over, and at what granularity. Opening balances and open documents are usually enough; full transactional history is usually a request nobody has costed. Whatever the answer, plan the &lt;a href="https://www.odoo.com/documentation/19.0/applications/essentials/export_import_data.html" rel="noopener noreferrer"&gt;import&lt;/a&gt; around stable external IDs so the load is re-runnable — you will run it more than once.&lt;/p&gt;

&lt;h2&gt;8. The question that eliminates the most custom code&lt;/h2&gt;

&lt;p&gt;Ask, for each process: &lt;em&gt;what do you do today when it goes wrong?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Not the happy path — the happy path is already in Odoo. What happens when the customer changes the order after picking has started, when a delivery is short, when a payment arrives without a reference, when someone posts to the wrong period. Exceptions are where custom development actually lives, and they're the part clients never think to describe because handling them is muscle memory.&lt;/p&gt;

&lt;p&gt;You'll also find that a third of the exceptions have a standard Odoo answer the client didn't know existed. That's the discovery work an &lt;a href="https://www.mastersoftwaresolutions.com/odoo-consulting-services/" rel="noopener noreferrer"&gt;Odoo consulting partner&lt;/a&gt; is really being paid for, and it's worth more than any amount of documentation.&lt;/p&gt;

&lt;h2&gt;The limitation worth stating&lt;/h2&gt;

&lt;p&gt;None of this produces complete requirements, and a checklist that claims otherwise is lying to you. Users cannot describe processes they perform automatically, and some requirements only become visible once someone sees a working screen and says "no, not like that."&lt;/p&gt;

&lt;p&gt;The opposite failure is worse and more common: a 200-page specification written before anyone has touched Odoo, which faithfully documents the legacy system's workarounds and gets built as custom modules. Some of the most valuable output of a requirements phase is the list of things the client asked for and then agreed not to build. Keep that list. It's the one you'll reread when someone asks in year three why the system works the way it does.&lt;/p&gt;

&lt;p&gt;Get a sandbox in front of the client in week two. Half your open questions answer themselves, and &lt;a href="https://www.odoo.com/documentation/19.0/applications/studio/models_modules_apps.html" rel="noopener noreferrer"&gt;Studio&lt;/a&gt; is fast enough to prototype a model and a form view in the meeting itself — which is a much better way to find out you've misunderstood the noun.&lt;/p&gt;





&lt;p&gt;&lt;strong&gt;Author bio:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The team at &lt;a href="https://www.mastersoftwaresolutions.com/" rel="noopener noreferrer"&gt;Master Software Solutions&lt;/a&gt; works on ERP consulting and AI engineering, with a focus on Odoo implementations, integrations, and custom module development for manufacturing, distribution, and food and beverage operations. They write about ERP delivery, requirements practice, and process automation.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>python</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>How to Rescue a Failed Odoo Implementation: A Consultant's Triage Playbook</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:48:24 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/how-to-rescue-a-failed-odoo-implementation-a-consultants-triage-playbook-38fl</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/how-to-rescue-a-failed-odoo-implementation-a-consultants-triage-playbook-38fl</guid>
      <description>&lt;p&gt;The call usually comes about eleven months in. Go-live happened, sort of. Finance is still closing the month in a spreadsheet, the warehouse team keeps a parallel notebook, and someone has quietly stopped using the CRM entirely. The system technically works. Nobody trusts it.&lt;/p&gt;

&lt;p&gt;Odoo rarely fails because Odoo is bad software. It fails because the implementation encoded somebody's misunderstanding of the business into 40 custom modules, and now every fix breaks two things. Panorama Consulting's &lt;a href="https://www.panorama-consulting.com/resource-center/erp-report-archives/" rel="noopener noreferrer"&gt;2026 ERP Report&lt;/a&gt; still puts cost overruns and schedule slippage among the most persistent problems across ERP projects of every size — and in our experience the overrun is almost never in licensing. It's in the rework.&lt;/p&gt;

&lt;p&gt;Here's the triage sequence we actually run when we inherit a broken deployment, in the order we run it.&lt;/p&gt;

&lt;h2&gt;Step 1: Read the database before you read the code&lt;/h2&gt;

&lt;p&gt;Skip the codebase for a day. Open PostgreSQL and ask the system what people are really doing.&lt;/p&gt;

&lt;p&gt;A few queries tell you more than a week of stakeholder interviews:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Row counts per model over time.&lt;/strong&gt; If &lt;code&gt;crm.lead&lt;/code&gt; stopped growing in March, sales abandoned the module in March. Nobody will volunteer this in a meeting.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;&lt;code&gt;ir.model.fields&lt;/code&gt; where &lt;code&gt;state = 'manual'&lt;/code&gt;.&lt;/strong&gt; Every field created through Studio or a quick patch. A healthy mid-size deployment has a few dozen. We've opened databases with 900. That number is a direct measure of how much undocumented business logic is floating outside version control.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;&lt;code&gt;stock.quant&lt;/code&gt; versus what the warehouse counts.&lt;/strong&gt; Any gap here means inventory valuation is wrong, which means the P&amp;amp;L is wrong, which is usually the real reason finance went back to Excel.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;&lt;code&gt;ir_cron&lt;/code&gt; last-run timestamps and failure counts.&lt;/strong&gt; Silently dead crons are behind a surprising share of "the system doesn't update" complaints.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Direct SQL writes.&lt;/strong&gt; Grep the custom modules for &lt;code&gt;self.env.cr.execute&lt;/code&gt; with &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;INSERT&lt;/code&gt;. Every one of those bypasses the ORM, so computed fields never recomputed and stored values are now lying to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This step tells you which subsystems are actually broken versus which ones are merely unloved. They need very different fixes and people conflate them constantly.&lt;/p&gt;

&lt;h2&gt;Step 2: Separate the four failure types&lt;/h2&gt;

&lt;p&gt;Almost every rescue we've run sorts into four buckets, and the diagnosis determines everything after it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Failure type&lt;/th&gt;
      &lt;th&gt;Symptom&lt;/th&gt;
      &lt;th&gt;Typical fix&lt;/th&gt;
      &lt;th&gt;Effort&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Process mismatch&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Users maintain shadow spreadsheets; the Odoo flow needs 9 clicks for something they do 200 times a day&lt;/td&gt;
      &lt;td&gt;Re-map the process, then reconfigure — usually less code, not more&lt;/td&gt;
      &lt;td&gt;Medium&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Bad customization&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Core modules edited in place, monkeypatched methods, upgrade-blocking overrides&lt;/td&gt;
      &lt;td&gt;Refactor to proper inheritance; delete what duplicates standard behaviour&lt;/td&gt;
      &lt;td&gt;High&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Dirty data&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Duplicate &lt;code&gt;res.partner&lt;/code&gt; records, opening balances that don't tie, negative stock&lt;/td&gt;
      &lt;td&gt;Cleanse, reconcile, re-open a controlled migration window&lt;/td&gt;
      &lt;td&gt;Medium–High&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;No ownership&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;It works, but nobody was trained and no one owns the master data&lt;/td&gt;
      &lt;td&gt;Internal key-user model, documentation, defined data stewardship&lt;/td&gt;
      &lt;td&gt;Low cost, high resistance&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Type 4 is the one clients least want to hear and the one that most often decides whether the rescue holds a year later. You can fix code in a sprint. You cannot fix "no one owns the product master" with a sprint.&lt;/p&gt;

&lt;h2&gt;Step 3: Audit the customizations against the upgrade path&lt;/h2&gt;

&lt;p&gt;This is where most inherited deployments are genuinely dangerous. Odoo only provides support and bug fixes for its three most recent major versions (&lt;a href="https://www.odoo.com/documentation/17.0/administration/supported_versions.html" rel="noopener noreferrer"&gt;supported versions policy&lt;/a&gt;), so a deployment stuck two versions back on unupgradeable custom code has a hard deadline whether or not anyone has written it down.&lt;/p&gt;

&lt;p&gt;What we look for, roughly in descending order of how much it will hurt:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
&lt;strong&gt;Edits to core addons in place.&lt;/strong&gt; Someone changed &lt;code&gt;addons/sale/models/sale_order.py&lt;/code&gt; directly. Every future update overwrites it. This is the single most common cause of "we can't upgrade."&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Overrides that duplicate standard functionality.&lt;/strong&gt; A custom pricing engine that reimplements pricelists. A custom approval flow where &lt;code&gt;base.group_&lt;/code&gt; rules and activities would have done it. Deleting code is the highest-ROI work in a rescue.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Missing or wrong &lt;code&gt;depends&lt;/code&gt; declarations&lt;/strong&gt; on computed fields — the reason stored values drift from reality and nobody can reproduce it on demand.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Data model abuse.&lt;/strong&gt; &lt;code&gt;char&lt;/code&gt; fields holding what should be &lt;code&gt;many2one&lt;/code&gt; relations, so reporting is impossible without string parsing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Practical note: run the audit against a restored production copy on the target version with &lt;a href="https://github.com/OCA/OpenUpgrade" rel="noopener noreferrer"&gt;OCA's OpenUpgrade&lt;/a&gt; scripts, not against a clean demo database. Demo data hides exactly the constraint violations you need to find.&lt;/p&gt;

&lt;p&gt;By the end of this step you should be able to sort every custom module into keep, refactor, or delete — with a rough hour estimate on each. That list, not a strategy deck, is the rescue plan. When we scope this as an &lt;a href="https://www.mastersoftwaresolutions.com/odoo-consulting-services/" rel="noopener noreferrer"&gt;Odoo consulting partner&lt;/a&gt; brought in mid-project, the delete column is usually the largest of the three, and clients are consistently surprised by that.&lt;/p&gt;

&lt;h2&gt;Step 4: Stabilise before you improve&lt;/h2&gt;

&lt;p&gt;There's a strong temptation to fix everything in one heroic release. Don't. A failed implementation has already spent the organisation's patience, and the next visible failure is often fatal to the project politically.&lt;/p&gt;

&lt;p&gt;Sequence it:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Weeks 1–2: stop the bleeding.&lt;/strong&gt; Fix the crons, correct inventory valuation, get one clean month-end close. Nothing else.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Weeks 3–6: retire the shadow systems, one team at a time.&lt;/strong&gt; Pick the team with the most pain and the least political weight. Get a real win you can point to.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Weeks 7+: the refactor and upgrade path.&lt;/strong&gt; Only now touch the custom module debt, with tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stabilisation phase produces almost no new features and it is the phase that saves the project. A single clean close does more for user trust than any amount of roadmap.&lt;/p&gt;

&lt;h2&gt;When rescue is the wrong answer&lt;/h2&gt;

&lt;p&gt;Sometimes the honest recommendation is to stop patching. We've walked into two situations where a rebuild on a fresh database was cheaper than a rescue:&lt;/p&gt;

&lt;p&gt;The first is when the chart of accounts itself was configured wrong at go-live and a year of transactions sits on top of it. Restructuring live accounting data is slow, risky, and auditors hate it. Starting a new database with correct configuration and migrating open balances only is frequently the lower-risk path.&lt;/p&gt;

&lt;p&gt;The second is when custom code exceeds roughly half the functional footprint. At that point you aren't running Odoo, you're running a bespoke application that happens to import from Odoo, and every upgrade will cost what the original build cost. Rebuilding against standard functionality — accepting that a few processes will change to match the software instead of the reverse — is usually cheaper over three years.&lt;/p&gt;

&lt;p&gt;Both conversations are uncomfortable, and both are better had in month one of the rescue than month eight. The trade-off is real, though: a rebuild resets user trust to zero a second time, and some organisations genuinely cannot survive that. If adoption is already fragile, a slower rescue on flawed foundations can be the right call even when it's the technically worse one.&lt;/p&gt;

&lt;h2&gt;Where to start tomorrow&lt;/h2&gt;

&lt;p&gt;If you're currently living inside a struggling implementation, do one thing this week: restore a production copy to a staging server and run the queries in Step 1. Count the manual fields. Check the cron failures. Diff &lt;code&gt;stock.quant&lt;/code&gt; against a physical count on one warehouse.&lt;/p&gt;

&lt;p&gt;You'll have a factual picture of what's broken within a day, and it will almost certainly differ from the story being told in status meetings. Every rescue we've run well started with that gap.&lt;/p&gt;





&lt;p&gt;&lt;em&gt;The team at &lt;a href="https://www.mastersoftwaresolutions.com/" rel="noopener noreferrer"&gt;Master Software Solutions&lt;/a&gt; is an ERP consulting and AI engineering firm that helps mid-size businesses automate operations with Odoo and custom AI agents. They write about ERP adoption, implementation recovery, and business process automation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>management</category>
      <category>software</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Odoo Community to Enterprise: What the Migration Actually Involves</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:18:53 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/odoo-community-to-enterprise-what-the-migration-actually-involves-13im</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/odoo-community-to-enterprise-what-the-migration-actually-involves-13im</guid>
      <description>&lt;p&gt;"Migrating from Community to Enterprise" is one of those phrases that hides two different projects inside it. Ask five teams what they mean by it and the answers range from "we flipped a switch over lunch" to "we spent three months and we're still cleaning up." Both are telling the truth. The gap between those two experiences is the part almost nobody scopes before they start — and it's what this post is about.&lt;/p&gt;

&lt;p&gt;I'll walk through what the move actually involves in practice: what's genuinely trivial, what quietly eats your timeline, and what you're really paying for once the invoice arrives.&lt;/p&gt;

&lt;h2&gt;Two migrations wearing the same name&lt;/h2&gt;

&lt;p&gt;There are two separate operations people lump together here.&lt;/p&gt;

&lt;p&gt;The first is an &lt;strong&gt;edition switch&lt;/strong&gt;: you stay on the same Odoo version and move from Community to Enterprise. Same major release, same database, different code and a license key.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;version upgrade&lt;/strong&gt;: you move the database itself forward — say Odoo 15 to Odoo 19 — with all the schema changes that implies.&lt;/p&gt;

&lt;p&gt;Most "Community to Enterprise" requests are secretly both. Teams asking for Enterprise are usually sitting on an older Community release and want the current apps, which means they need to change edition &lt;em&gt;and&lt;/em&gt; jump several versions. Figuring out which of these you're actually signing up for is the first real decision, because they carry completely different risk profiles.&lt;/p&gt;

&lt;h2&gt;The edition switch is the easy 20%&lt;/h2&gt;

&lt;p&gt;On the same version, switching editions is a deployment task, not a data migration. Odoo's own &lt;a href="https://www.odoo.com/documentation/19.0/administration/on_premise/community_to_enterprise.html" rel="noopener noreferrer"&gt;documentation for moving from Community to Enterprise&lt;/a&gt; is short for a reason. On a self-hosted instance you point your &lt;code&gt;addons_path&lt;/code&gt; at the Enterprise code, install one module, and register your subscription:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;addons_path = /opt/odoo/enterprise,/opt/odoo/addons&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Enterprise has to come first in that list so its modules take priority over the Community versions. Restart the service, install &lt;code&gt;web_enterprise&lt;/code&gt; (that's the module that flips the database into Enterprise mode), then activate your subscription code under Settings. Your PostgreSQL data doesn't move. Nothing gets rewritten.&lt;/p&gt;

&lt;p&gt;If you're heading to Odoo Online or Odoo.sh instead of self-hosting, you don't even do this by hand — both platforms only run Enterprise, so the "switch" is really a decision about where you host.&lt;/p&gt;

&lt;p&gt;That part fits in an afternoon. It's also the part people fixate on, which is exactly why projects that look simple on paper still blow their estimates.&lt;/p&gt;

&lt;h2&gt;The part that actually eats the schedule&lt;/h2&gt;

&lt;p&gt;The real work is deciding what happens to every module you've already got installed.&lt;/p&gt;

&lt;p&gt;A mature Community deployment is rarely stock. It's core Odoo, plus a layer of OCA community modules, plus whatever custom code someone wrote to fill the gaps. Enterprise ships its own versions of a lot of that functionality, and now they collide. Community accounting (&lt;code&gt;account&lt;/code&gt;) versus Enterprise accounting (&lt;code&gt;account_accountant&lt;/code&gt;). OCA HR and payroll modules versus the Enterprise HR suite. Custom views and automations you built by hand that Enterprise's Studio now handles natively.&lt;/p&gt;

&lt;p&gt;For each of those you have to make a call: keep it, replace it with the Enterprise equivalent, or migrate the data out before you uninstall it. Get it wrong and you get two modules writing to overlapping tables, duplicated records, and reports that quietly disagree with each other. Deciding what to keep and what to retire is judgment work rather than something you can script, and it's the main reason teams bring in an experienced &lt;a href="https://www.mastersoftwaresolutions.com/odoo-migration-services/" rel="noopener noreferrer"&gt;Odoo migration partner&lt;/a&gt; instead of toggling &lt;code&gt;web_enterprise&lt;/code&gt; on a Friday and hoping the weekend holds.&lt;/p&gt;

&lt;p&gt;None of this shows up in a quick demo. It shows up three days in, when someone notices the tax report changed.&lt;/p&gt;

&lt;h2&gt;When a version upgrade rides along&lt;/h2&gt;

&lt;p&gt;If you're also jumping versions — and most people are — that's where the genuine data migration lives.&lt;/p&gt;

&lt;p&gt;Odoo SA doesn't offer an upgrade path for Community databases; its upgrade platform is aimed at Enterprise customers. For Community, the established route is the OCA's &lt;a href="https://oca.github.io/OpenUpgrade/" rel="noopener noreferrer"&gt;OpenUpgrade&lt;/a&gt; project, a set of migration scripts that move a database from one major version to the next. The important constraint: it works one major version at a time. Going from 15 to 19 means 15 → 16 → 17 → 18 → 19, and every hop can bring renamed fields, restructured models, and modules that no longer exist.&lt;/p&gt;

&lt;p&gt;Before you touch any of it, back up the database &lt;em&gt;and&lt;/em&gt; the filestore — the filestore holds your attachments and images, and a &lt;code&gt;pg_dump&lt;/code&gt; without it gives you a broken restore. Then run the whole sequence on a copy and have real users test their daily workflows on the result. In the migrations we've run, testing reliably takes longer than the migration scripts themselves, and that's the healthy ratio, not a warning sign.&lt;/p&gt;

&lt;h2&gt;What you're actually buying&lt;/h2&gt;

&lt;p&gt;Community is free and licensed under the LGPLv3. Enterprise is a proprietary per-user subscription — roughly $25 to $31 per user per month on the Standard plan in 2026 depending on region and billing terms, with the Custom tier higher and a One App Free plan at the bottom. Current numbers live on &lt;a href="https://www.odoo.com/pricing" rel="noopener noreferrer"&gt;Odoo's pricing page&lt;/a&gt;, and they shift by country, so check your own.&lt;/p&gt;

&lt;p&gt;The subscription also buys you back into the support window. Odoo actively supports &lt;a href="https://www.odoo.com/documentation/19.0/administration/standard_extended_support.html" rel="noopener noreferrer"&gt;its three most recent major versions&lt;/a&gt;; run something older and you're either unsupported or paying a surcharge to stay covered. For a lot of teams that support timeline, not the feature list, is the actual trigger for the move.&lt;/p&gt;

&lt;p&gt;Here's the honest limitation, though: Enterprise isn't automatically an upgrade. If you've built on solid OCA modules, part of what you're "gaining" is paid replacements for things that already worked — plus a recurring bill and a little less freedom to self-host however you like. The real wins are genuine (Studio, full accounting, mobile apps, supported upgrades, the newer AI features), but they're worth pricing honestly against what you're giving up, not assumed.&lt;/p&gt;

&lt;h2&gt;Start with the audit, not the license&lt;/h2&gt;

&lt;p&gt;If there's one thing to take away, it's the order of operations. Before you buy a single Enterprise seat, run a module audit:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Inventory every installed module and tag each one as core, OCA, or custom.&lt;/li&gt;
  &lt;li&gt;Map each OCA and custom module to its Enterprise equivalent, or mark it "keep."&lt;/li&gt;
  &lt;li&gt;Confirm your target version sits inside the supported window.&lt;/li&gt;
  &lt;li&gt;Take a full backup including the filestore, and restore it somewhere to prove it actually works.&lt;/li&gt;
  &lt;li&gt;Rehearse the entire switch on a staging copy and let real users run their daily flows against it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The license is the cheap, reversible part. The module and version decisions are what you'll live with for years. If the audit comes back showing you're mostly on core apps, your switch really might be a quiet afternoon. If it turns up a stack of OCA modules and homegrown views, you've just found your actual project — and it is far cheaper to find that now than halfway through cutover weekend.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>software</category>
    </item>
  </channel>
</rss>
