<?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>From 43 Spreadsheets to One Odoo Database: Notes From a Canadian Rollout</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:49:22 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/from-43-spreadsheets-to-one-odoo-database-notes-from-a-canadian-rollout-3d7l</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/from-43-spreadsheets-to-one-odoo-database-notes-from-a-canadian-rollout-3d7l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a composite of three Canadian SMB projects, with names and details changed. The numbers are illustrative of what we saw, not audited figures from one client.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The file was called &lt;code&gt;MASTER_inventory_FINAL_v7_USE THIS ONE.xlsx&lt;/code&gt;. There was also a &lt;code&gt;v8&lt;/code&gt;. Nobody was sure which one the warehouse used.&lt;/p&gt;

&lt;p&gt;That was week one at a 60-person building-supplies distributor with a yard in Ontario and a smaller one in BC. They weren't disorganised. They'd grown for fifteen years on spreadsheets because spreadsheets worked, right up until the day they didn't. When we finished the inventory of every file that ran part of the business, the count was 43.&lt;/p&gt;

&lt;p&gt;This post is about how that got down to one database, and the parts that were harder than the software.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: inventory the spreadsheets, not the processes
&lt;/h2&gt;

&lt;p&gt;Most ERP projects start with process mapping workshops. We did those too, but later. The first thing was a plain list of every spreadsheet that someone opened at least once a week, with four columns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;file name and location (shared drive, someone's desktop, email attachment)&lt;/li&gt;
&lt;li&gt;owner, meaning the one person who'd notice if it broke&lt;/li&gt;
&lt;li&gt;what it feeds (another sheet, the accounting system, a customer)&lt;/li&gt;
&lt;li&gt;what breaks if it's wrong for a day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last column did most of the work. It split the 43 files into roughly three groups: about a dozen that directly moved money or stock, about twenty that were reporting built on top of those, and the rest, which were personal trackers nobody else relied on.&lt;/p&gt;

&lt;p&gt;The reporting sheets were the easy win. Once the source data lives in one place, most of them simply disappear. The personal trackers we left alone. The dozen money-and-stock sheets were the actual project.&lt;/p&gt;

&lt;p&gt;Research has been saying this for a long time. Raymond Panko's review of &lt;a href="https://arxiv.org/abs/0802.3457" rel="noopener noreferrer"&gt;spreadsheet error studies&lt;/a&gt; concluded that errors are "both common and non-trivial". What surprised the team here wasn't that errors existed. It was how many of them were copy-paste links between files that had silently broken months earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cleanup nobody budgets for
&lt;/h2&gt;

&lt;p&gt;Before anything went into Odoo, the customer and product lists had to be merged across both yards. That's where the time went.&lt;/p&gt;

&lt;p&gt;We ran a quick duplicate check in pandas before touching the import tool. It's crude, but it finds the obvious problems in minutes:&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;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customers_on.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customers_bc.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;ignore_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="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# normalise the fields people type differently
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name_key&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^A-Z0-9]&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;regex&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(INC|LTD|LIMITED|CORP)$&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;regex&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;regex&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;

&lt;span class="n"&gt;dupes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duplicated&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duplicated&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;dupes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review_duplicates.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&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;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produced about 300 candidate duplicates out of roughly 2,100 customers. Around two thirds were real. The rest were legitimately separate accounts: a head office and a branch of the same contractor, each with its own billing terms.&lt;/p&gt;

&lt;p&gt;No script decides that last part for you. Someone from accounts receivable sat with the list for most of two days. Plan for that. When teams compare &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;odoo implementation services&lt;/a&gt;, this is the line item worth asking about: who does the merge review, and how many days are in the quote for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  External IDs saved us twice
&lt;/h2&gt;

&lt;p&gt;Odoo's importer takes &lt;a href="https://www.odoo.com/documentation/19.0/applications/essentials/export_import_data.html" rel="noopener noreferrer"&gt;Excel or CSV files&lt;/a&gt;, and the documentation strongly advises not removing the External ID column from the templates. Take that seriously. We gave every record a stable key before the first import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;categ&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;product&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;yard&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="k"&gt;x&lt;/span&gt;&lt;span class="mf"&gt;4&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;ft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="k"&gt;x&lt;/span&gt;&lt;span class="mf"&gt;4&lt;/span&gt; &lt;span class="k"&gt;SPF&lt;/span&gt; &lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;ft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;LUM&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2408&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;cat&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;lumber&lt;/span&gt;
&lt;span class="k"&gt;product&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;yard&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;osb&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="mf"&gt;716&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;OSB&lt;/span&gt; &lt;span class="mf"&gt;7&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;16&lt;/span&gt; &lt;span class="mf"&gt;4&lt;/span&gt;&lt;span class="k"&gt;x&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SHT&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0716&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;cat&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;sheet&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;goods&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things made this worth the effort. First, imports are permanent and can't be undone, so when the first product load had wrong units of measure on 140 items, we fixed the spreadsheet and re-imported against the same IDs instead of hunting down records by hand. Second, the vendor price lists and reordering rules referenced products by &lt;code&gt;id&lt;/code&gt;, not by name, so a renamed product didn't break every file that pointed at it.&lt;/p&gt;

&lt;p&gt;Always hit &lt;strong&gt;Test&lt;/strong&gt; before &lt;strong&gt;Import&lt;/strong&gt;. It catches most mapping errors without writing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the spreadsheets were hiding a process
&lt;/h2&gt;

&lt;p&gt;The hardest sheet was the one the sales desk used to price special orders. It looked like a price list. It was really an approval workflow: margin below 18% meant the cell turned red, and a red cell meant you walked over to the sales manager's desk.&lt;/p&gt;

&lt;p&gt;That rule lived in conditional formatting and in one person's habits. In Odoo, it became an approval rule on quotations below a margin threshold. Standard Sales doesn't do margin-based approvals out of the box, so this was one of the few small customizations in the project. It took a day to build and a month to get people to stop walking over anyway.&lt;/p&gt;

&lt;p&gt;This pattern is common in distribution. We wrote separately about &lt;a href="https://www.mastersoftwaresolutions.com/us-wholesale-distributors-losing-margin-manual-order-processing/" rel="noopener noreferrer"&gt;how manual order processing erodes margin&lt;/a&gt; for wholesale businesses, and nearly every case traces back to a rule that only existed in someone's spreadsheet.&lt;/p&gt;

&lt;p&gt;The two yards added another wrinkle: separate stock, a shared customer list, and different provincial sales tax (HST in Ontario, GST plus PST in BC). That's a warehouse-and-company structure question you want answered in week two, not week ten. There's a longer answer to &lt;a href="https://www.mastersoftwaresolutions.com/odoo-multi-location-and-multi-country-structure/" rel="noopener noreferrer"&gt;whether Odoo can handle a multi-location structure&lt;/a&gt; if you're facing the same decision. Here it was one company, two warehouses and fiscal positions per province.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after (illustrative)
&lt;/h2&gt;

&lt;p&gt;These are the kind of figures we tracked. Treat them as a shape, not a benchmark.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheets in weekly use:&lt;/strong&gt; 43 before, 6 after (the personal trackers, plus a commission sheet sales refused to give up)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month-end close:&lt;/strong&gt; about 9 working days before, about 4 after three months live&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stock count variance at the BC yard:&lt;/strong&gt; noticeably lower once receipts were scanned instead of keyed from paper&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Which version is current?" messages in the team chat:&lt;/strong&gt; gone within a month, which the owner said was the only metric he cared about&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The close improvement didn't show up in month one. Month one was slower than the old way. It showed up in month three, once people stopped double-checking the new system against the old sheets.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;p&gt;We tried to migrate five years of transaction history. It was a mistake. The old data was inconsistent enough that importing it created reconciliation noise for weeks. In the end we loaded open items and opening balances only, and kept the old files read-only for lookups. If you're planning your own move, start there and add history only if someone can name the report that needs it.&lt;/p&gt;

&lt;p&gt;We also underestimated training at the second yard. The Ontario team had been in the workshops; BC had only seen demos. Their first two weeks were rough.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're starting this
&lt;/h2&gt;

&lt;p&gt;Do the spreadsheet inventory first, before any demo or workshop. It'll tell you how big the project really is. If you bring in outside help, favour the team that asks to see your spreadsheets in the first meeting. The ones that start with a feature tour usually find the hidden processes late.&lt;/p&gt;

&lt;p&gt;Then pick your top dozen money-and-stock files, and write down the rule each one is secretly enforcing. That list is your real requirements document.&lt;/p&gt;

</description>
      <category>odoo</category>
      <category>erp</category>
      <category>python</category>
      <category>canada</category>
    </item>
    <item>
      <title>Job Costing in Odoo for Canadian Construction Firms: Analytic Plans, Holdbacks and Progress Billing</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:51:02 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/job-costing-in-odoo-for-canadian-construction-firms-analytic-plans-holdbacks-and-progress-billing-4g33</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/job-costing-in-odoo-for-canadian-construction-firms-analytic-plans-holdbacks-and-progress-billing-4g33</guid>
      <description>&lt;p&gt;Most construction ERP rollouts I have seen go wrong in the same quiet way. The accounting migration lands fine, the projects get created, invoices go out — and four months later the controller still cannot tell you which of the eleven active jobs is making money, because every cost hit a single analytic account called "Projects".&lt;/p&gt;

&lt;p&gt;That is not a module problem. It is a structure decision made in week two of configuration and then inherited for years. The &lt;a href="https://www.mastersoftwaresolutions.com/erp-discovery-phase-save-hours-of-rework/" rel="noopener noreferrer"&gt;discovery phase&lt;/a&gt; is where it should be settled, and for contractors it comes down to four questions the system has to answer on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions a contractor's system has to answer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;What has this job cost me so far, split by cost code, and how does that compare to the estimate?&lt;/li&gt;
&lt;li&gt;What have I committed but not yet been billed for — the open POs and unapproved subcontractor progress claims?&lt;/li&gt;
&lt;li&gt;What can I legally bill this month, and what portion of it must sit as holdback?&lt;/li&gt;
&lt;li&gt;When my sub sends a proper invoice, what is my payment clock?
Odoo answers the first and third out of the box if you set it up correctly. The second needs work. The fourth is a legal deadline, not a feature, and it is the one that generates penalties.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Two analytic plans, not one
&lt;/h2&gt;

&lt;p&gt;Odoo 19's &lt;a href="https://www.odoo.com/documentation/19.0/applications/finance/accounting/reporting/analytic_accounting.html" rel="noopener noreferrer"&gt;analytic accounting&lt;/a&gt; works on plans that group analytic accounts, and a journal item can carry a distribution across several plans at once. That second part is what makes proper job costing possible, and it is the part teams skip.&lt;/p&gt;

&lt;p&gt;Enable it under &lt;strong&gt;Accounting ‣ Configuration ‣ Settings ‣ Analytics&lt;/strong&gt;, then build two plans under &lt;strong&gt;Accounting ‣ Configuration ‣ Analytic Plans&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: Jobs                      Default Applicability: Mandatory
  ├── 2026-014  Riverbend Retrofit
  ├── 2026-021  Kanata Warehouse Phase 2
  └── OH-000    Overhead / unassigned

Plan: Cost Codes                Default Applicability: Mandatory
  ├── 01  General conditions
  ├── 03  Concrete
  ├── 15  Mechanical
  ├── 16  Electrical
  └── 99  Rework and warranty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every cost line then carries both a job and a cost code, and you can slice either way without a custom report. Setting &lt;strong&gt;Default Applicability&lt;/strong&gt; to &lt;em&gt;Mandatory&lt;/em&gt; means an entry cannot be confirmed with the field empty — annoying for a week, and the reason your numbers are still trustworthy in year three.&lt;/p&gt;

&lt;p&gt;Use the &lt;strong&gt;Applicability&lt;/strong&gt; tab to stop the mandatory rule from firing where it makes no sense. A line for corporate insurance or a bank fee does not belong to a job. Set an applicability line with the financial account prefixes that should be exempt, or the AP clerk will start assigning everything to OH-000 to get past the validation, which is worse than having no rule.&lt;/p&gt;

&lt;p&gt;Keep the cost-code list short at the start. In the rollouts we have run, contractors who launched with 12 to 20 codes were still using them a year later; the firm that imported its full 180-code legacy list ended up with 90% of costs sitting in four codes and no useful comparison against estimate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automating the distribution you can predict
&lt;/h3&gt;

&lt;p&gt;Some costs always land the same way. Under &lt;strong&gt;Accounting ‣ Configuration ‣ Analytic Distribution Models&lt;/strong&gt; you can pin a distribution to an account prefix, a partner, a product or a product category — so equipment depreciation splits 60/30/10 across divisions automatically, and the clerk never sees the prompt. Models can be sequenced and combined across plans.&lt;/p&gt;

&lt;p&gt;This is the difference between a costing structure people maintain and one they resent. If a contractor is picking the same cost code 40 times a week, that is a distribution model waiting to be created, and it is exactly the kind of detail worth nailing down during &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;odoo implementation services&lt;/a&gt; rather than six months after go-live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress billing: milestones or percent complete
&lt;/h2&gt;

&lt;p&gt;Odoo supports &lt;a href="https://www.odoo.com/documentation/19.0/applications/sales/sales/invoicing/milestone.html" rel="noopener noreferrer"&gt;milestone invoicing&lt;/a&gt; natively. Create the service product with &lt;strong&gt;Product Type: Service&lt;/strong&gt;, set &lt;strong&gt;Create on Order&lt;/strong&gt; to &lt;em&gt;Project and Task&lt;/em&gt;, and set the &lt;strong&gt;Invoicing Policy&lt;/strong&gt; to &lt;em&gt;Based on Milestones&lt;/em&gt;. Milestones carry a &lt;strong&gt;Delivered %&lt;/strong&gt;; marking one reached updates the delivered quantity on the sales order line, and you invoice from there. Reaching a milestone does not create the invoice by itself — someone still clicks &lt;strong&gt;Create Invoice&lt;/strong&gt;, which is the behaviour you want when a payment certifier is in the loop.&lt;/p&gt;

&lt;p&gt;That fits fixed-price work with defined phases. It does not fit a CCDC-style monthly progress claim priced by unit quantities installed, where the number changes every month based on site measurement. For those, the pattern that has held up for us is a sales order line per schedule-of-values item, with quantities updated monthly from the field measurement, then invoiced on delivered quantity. It is more clicking. It also matches what the consultant actually signs.&lt;/p&gt;

&lt;p&gt;Standard Odoo will not give you a construction-grade schedule of values with retainage columns, previous-billings carry-forward and stored materials, and you should budget for a light custom report if the owner's certifier expects that layout. Pretending the standard invoice PDF will pass is how a project loses a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Holdback: 10%, and Odoo has no field for it
&lt;/h2&gt;

&lt;p&gt;Ontario's &lt;em&gt;Construction Act&lt;/em&gt; defines holdback as &lt;a href="https://www.ontario.ca/laws/statute/90c30" rel="noopener noreferrer"&gt;10 per cent of the value of the services or materials supplied under a contract&lt;/a&gt;. Other provinces run similar regimes under their own lien legislation with their own release rules, so confirm the number and the release trigger for each province you work in before configuring anything.&lt;/p&gt;

&lt;p&gt;There is no holdback checkbox in Odoo. Two workable approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoice the full progress claim and let the 10% sit open in AR.&lt;/strong&gt; Simple, and it wrecks your aging report — every certified job shows a permanently overdue balance, and collections calls start going out on money you are not entitled to yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoice the full claim with a negative holdback line routed to a dedicated Holdback Receivable account.&lt;/strong&gt; The customer's open balance equals what they actually owe this month, and the retained amount accumulates in its own account you can age separately by job. Release is a positive line on the certification invoice.&lt;/p&gt;

&lt;p&gt;We use the second. It needs one product ("Holdback retained — 10%") whose income account points at the holdback asset account, and your accountant will need convincing that a product is the right vehicle. The alternative — a manual journal entry per claim — gets skipped the first busy month, and then the balance is wrong.&lt;/p&gt;

&lt;p&gt;Either way, model it before the first invoice. Retrofitting holdback across nine months of posted claims is a reconciliation exercise nobody has time for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payment clocks are legal deadlines, not reminders
&lt;/h2&gt;

&lt;p&gt;Since prompt payment came into force, the dates on your documents carry consequences. Under the Ontario Act, an owner has &lt;strong&gt;28 days&lt;/strong&gt; after receiving a proper invoice to pay the contractor (s. 6.4); the contractor then has &lt;strong&gt;7 days&lt;/strong&gt; to pay subcontractors out of what it received (s. 6.5); and where the owner does not pay, the contractor must give subs notice of non-payment within &lt;strong&gt;14 days&lt;/strong&gt;. Adjudication exists to resolve the fight quickly, which means your records get read by someone who was not there.&lt;/p&gt;

&lt;p&gt;Practically, in Odoo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the &lt;em&gt;proper invoice received&lt;/em&gt; date as its own field on vendor bills. &lt;strong&gt;Bill Date&lt;/strong&gt; is whatever the sub typed; the clock runs from receipt.&lt;/li&gt;
&lt;li&gt;Automate the sub-payment reminder off that field, not off the standard due date.&lt;/li&gt;
&lt;li&gt;Keep the certifier's approval as an attachment on the invoice, not in someone's mailbox.
None of this is exotic configuration. It is the sort of thing that gets left out of scope because it looks administrative, and then becomes the reason a payment dispute costs more than the ERP did. Our notes on &lt;a href="https://www.mastersoftwaresolutions.com/best-practices-during-odoo-erp-implementation/" rel="noopener noreferrer"&gt;implementation best practices&lt;/a&gt; cover the same idea in a general setting: the fields that carry legal weight get configured first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to have working before go-live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two analytic plans, mandatory, with applicability exemptions for non-job accounts&lt;/li&gt;
&lt;li&gt;A cost-code list you can defend at 20 items or fewer&lt;/li&gt;
&lt;li&gt;Distribution models for the five or six recurring costs&lt;/li&gt;
&lt;li&gt;One holdback product and account, with a documented release procedure&lt;/li&gt;
&lt;li&gt;Progress-claim format shown to one real certifier and confirmed acceptable&lt;/li&gt;
&lt;li&gt;Proper-invoice-received date captured on vendor bills
Six items. Getting them right is roughly two weeks of a configuration consultant's time, and it is the cheapest two weeks in the project — far cheaper than the three most &lt;a href="https://www.mastersoftwaresolutions.com/common-odoo-implementation-mistakes-and-how-to-avoid-them/" rel="noopener noreferrer"&gt;common implementation mistakes&lt;/a&gt;, all of which show up as bad numbers rather than broken software. Before you sign off on a construction rollout, sit with your controller, open a job that is already half-built, and see whether the system can tell you what it has cost. If it cannot, the structure is wrong, and no amount of &lt;a href="https://www.mastersoftwaresolutions.com/what-are-the-key-modules-in-the-odoo-erp-system/" rel="noopener noreferrer"&gt;module selection&lt;/a&gt; will fix it.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>An Hour-by-Hour ERP Cutover Playbook for Canadian Distributors</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:10:56 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/an-hour-by-hour-erp-cutover-playbook-for-canadian-distributors-4e2h</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/an-hour-by-hour-erp-cutover-playbook-for-canadian-distributors-4e2h</guid>
      <description>&lt;p&gt;Cutover is the only part of an ERP project with no room to renegotiate. Configuration slips by a week and nobody outside the project notices. Cutover slips by four hours and a distribution business cannot ship on Monday.&lt;/p&gt;

&lt;p&gt;This is the plan I would hand a Canadian distributor moving from a legacy system to Odoo over a single weekend — roughly 60 hours, Friday evening to Monday morning. It assumes a warehouse that does not run Saturdays, one legal entity, and a production instance already provisioned. Adjust the arithmetic if any of that is different for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rehearse it twice, on real data
&lt;/h2&gt;

&lt;p&gt;The single strongest predictor of a clean cutover is how many times you have already done it.&lt;/p&gt;

&lt;p&gt;Two full rehearsals on a copy of production data, timed with a clock, is the minimum. Not "we tested the import" — the entire sequence, in order, with the same people running the same commands. The first rehearsal finds broken steps. The second finds the real durations, which will be longer than your estimates.&lt;/p&gt;

&lt;p&gt;On Odoo.sh this is what staging branches are for. Staging builds get a neutralized copy of the production database, and per the &lt;a href="https://www.odoo.com/documentation/19.0/administration/odoo_sh/getting_started/branches.html" rel="noopener noreferrer"&gt;branch documentation&lt;/a&gt; they are explicitly not treated as persistent storage — outgoing mail and scheduled actions are disabled so a rehearsal cannot email your customers. If you are self-hosting, do the same thing manually before anything else runs:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;ir_cron&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;ir_mail_server&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;fetchmail_server&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;base_automation&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forgetting that last one is how a rehearsal fires 4,000 order-confirmation emails from a test database. I have watched it happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agree the rollback trigger before Friday
&lt;/h2&gt;

&lt;p&gt;Write down, in advance, the condition under which you abandon the cutover and reopen the legacy system on Monday. Then write down the latest clock time at which that decision can still be executed.&lt;/p&gt;

&lt;p&gt;For a weekend cutover the decision point is usually Sunday around midday. Past that, there is not enough runway to restore the legacy system, re-key the weekend's work and open on time. So the trigger is a time, not a feeling: &lt;em&gt;if opening inventory is not reconciled and opening balances are not posted by 12:00 Sunday, we roll back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reason to write it down is that nobody makes this call well at 2am on Sunday with sunk cost in the room. Pre-committing removes the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The freeze schedule
&lt;/h2&gt;

&lt;p&gt;Freezes are the part clients push back on hardest, and the part that most reliably determines whether the weekend works.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Freeze from&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Master data (products, partners, UoM, pricelists)&lt;/td&gt;
&lt;td&gt;Wednesday 17:00&lt;/td&gt;
&lt;td&gt;Extracts have to be stable while you clean and load them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purchase orders&lt;/td&gt;
&lt;td&gt;Thursday 17:00&lt;/td&gt;
&lt;td&gt;Open POs become the migrated backlog; a PO raised Friday afternoon exists in neither system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales orders&lt;/td&gt;
&lt;td&gt;Friday 12:00&lt;/td&gt;
&lt;td&gt;Anything after this is keyed into Odoo on Monday&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goods receipts / shipments&lt;/td&gt;
&lt;td&gt;Friday 15:00&lt;/td&gt;
&lt;td&gt;Warehouse needs clear time before the physical count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy system, all write access&lt;/td&gt;
&lt;td&gt;Friday 18:00&lt;/td&gt;
&lt;td&gt;This is the extract point&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two days of master-data freeze sounds excessive until you have watched a vendor record created on Thursday afternoon fail to appear in Monday's system, and the AP clerk conclude the migration lost data.&lt;/p&gt;

&lt;p&gt;Give the sales team a written procedure for the frozen window — where to record orders taken Friday afternoon, and who keys them in Monday. A shared spreadsheet with a named owner is fine. Nothing is not fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Friday 18:00 to Monday 06:00
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fri 18:00–18:30&lt;/td&gt;
&lt;td&gt;Legacy write access revoked. Final full backup taken and checksummed.&lt;/td&gt;
&lt;td&gt;DBA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fri 18:30–20:00&lt;/td&gt;
&lt;td&gt;Final extracts pulled: master data deltas since Wednesday, open SOs, open POs, AR/AP aging, trial balance, on-hand stock by location.&lt;/td&gt;
&lt;td&gt;Data lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fri 20:00–23:00&lt;/td&gt;
&lt;td&gt;Physical count of high-value and fast-moving SKUs. Count sheets on paper, entered later.&lt;/td&gt;
&lt;td&gt;Warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sat 08:00–11:00&lt;/td&gt;
&lt;td&gt;Master data delta load. Row counts reconciled against extract.&lt;/td&gt;
&lt;td&gt;Data lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sat 11:00–14:00&lt;/td&gt;
&lt;td&gt;Opening inventory loaded as &lt;code&gt;stock.quant&lt;/code&gt; records. Reconciled to count sheets, not to the legacy report.&lt;/td&gt;
&lt;td&gt;Data lead + Warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sat 14:00–18:00&lt;/td&gt;
&lt;td&gt;Open SO and PO backlog loaded. Spot-check 20 documents against legacy printouts by hand.&lt;/td&gt;
&lt;td&gt;Ops lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sat 18:00–21:00&lt;/td&gt;
&lt;td&gt;Opening balances posted: trial balance as a journal entry, AR and AP as open items per invoice.&lt;/td&gt;
&lt;td&gt;Finance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun 08:00–11:00&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ir.sequence&lt;/code&gt; values set. Tax and fiscal positions verified against real invoices. Warehouse routes and operation types checked.&lt;/td&gt;
&lt;td&gt;Finance + Ops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun 11:00–12:00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reconciliation review. Go / rollback decision.&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Steering group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun 12:00–15:00&lt;/td&gt;
&lt;td&gt;Crons, mail servers and automation rules re-enabled. Integrations repointed. Smoke tests.&lt;/td&gt;
&lt;td&gt;Tech lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun 15:00–18:00&lt;/td&gt;
&lt;td&gt;Three end-to-end business transactions: quote to invoice, PO to receipt to bill, pick to pack to ship.&lt;/td&gt;
&lt;td&gt;Ops + Finance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun 18:00–20:00&lt;/td&gt;
&lt;td&gt;User access enabled. Comms sent. Floor-walker roster confirmed.&lt;/td&gt;
&lt;td&gt;Project manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mon 06:00&lt;/td&gt;
&lt;td&gt;First shift. Two floor-walkers per shift for the first week.&lt;/td&gt;
&lt;td&gt;Everyone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gaps in that table are deliberate. Overnight Saturday is not scheduled, because a team that has been awake for 30 hours makes the mistakes that turn a cutover into an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sequence numbers are the step everyone forgets
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ir.sequence&lt;/code&gt; controls invoice, order and picking numbering. If you leave it at the Odoo default, your first live customer invoice is INV/2026/00001 while your legacy system last issued 48,217.&lt;/p&gt;

&lt;p&gt;Your accountant will not enjoy that, your customers will query it against their POs, and fixing it retroactively means renumbering posted journal entries — which you should not do.&lt;/p&gt;

&lt;p&gt;Set the prefix and next number to continue the legacy series, on Sunday morning, and verify by creating and deleting one draft document of each type. Do the same for pickings, receipts and purchase orders. It takes twenty minutes and prevents a month of reconciliation questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile with counts, not with confidence
&lt;/h2&gt;

&lt;p&gt;Every load gets a row count comparison, extract against database, before anyone declares it done:&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="s1"&gt;'partners'&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;res_partner&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'products'&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;product_template&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'open_moves'&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;account_move&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'posted'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;payment_state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then three value checks that have to match the legacy system to the cent: AR total, AP total, and inventory valuation. If inventory valuation is off, find out whether it is a quantity problem or a costing-method problem before you touch anything — landed cost and average-cost differences between systems are common and are not data errors.&lt;/p&gt;

&lt;p&gt;Getting the opening-balance and valuation treatment right is the part of a migration where experience actually shows, and where an implementation partner earns its fee; it is a substantial share of what &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;Master Software Solutions&lt;/a&gt; spends its cutover weekends doing. The mechanics of extracting and reshaping legacy data are covered in more depth on their &lt;a href="https://www.mastersoftwaresolutions.com/odoo-migration-services/" rel="noopener noreferrer"&gt;Odoo migration&lt;/a&gt; pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not decommission the legacy system
&lt;/h2&gt;

&lt;p&gt;Keep it running, read-only, for at least a year.&lt;/p&gt;

&lt;p&gt;Beyond the obvious reason — someone will need a 2023 invoice — there is a Canadian compliance one. The CRA requires business records to be kept for &lt;a href="https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/keeping-records/where-keep-your-records-long-request-permission-destroy-them-early.html" rel="noopener noreferrer"&gt;six years from the end of the last tax year they relate to&lt;/a&gt;, with some categories held indefinitely. If you migrated two years of transactional history and the legacy system holds seven, switching it off does not satisfy that. Either migrate the full history, or keep the old system available in a documented read-only state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups: know what you actually have
&lt;/h2&gt;

&lt;p&gt;Take your own backup before cutover. Do not assume the platform's schedule covers the moment you care about.&lt;/p&gt;

&lt;p&gt;Odoo.sh keeps &lt;a href="https://www.odoo.sh/faq" rel="noopener noreferrer"&gt;14 full backups of each production instance for up to three months&lt;/a&gt; — daily for a week, weekly for a month, then monthly — and does not back up staging or development builds at all. That is a reasonable policy and it is not a cutover safety net. You want a manual dump immediately before the first live transaction, stored somewhere you control, with a restore you have actually tested.&lt;/p&gt;

&lt;p&gt;Restoring is the half nobody rehearses. A backup you have never restored is a file, not a recovery plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week one is part of cutover
&lt;/h2&gt;

&lt;p&gt;The weekend gets the plan. Week one gets the problems.&lt;/p&gt;

&lt;p&gt;Budget floor-walkers, not a help desk — someone physically standing where the work happens, because a picker will not raise a ticket, they will improvise a workaround and teach it to the next shift. Expect a productivity dip through week two and staff for it rather than being surprised by it. Keep a triage log with a daily 15-minute standup, and hold a single named person accountable for closing configuration issues within 48 hours. Whether that capacity comes from your own team or an external &lt;a href="https://www.mastersoftwaresolutions.com/odoo-support-and-maintenance-services/" rel="noopener noreferrer"&gt;support and maintenance&lt;/a&gt; arrangement matters less than it being explicitly resourced before Monday, which is where most plans quietly leave a gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation of a weekend cutover
&lt;/h2&gt;

&lt;p&gt;It only works when the business genuinely stops. A distributor that ships six days a week, or runs a customer-facing e-commerce channel that cannot pause, does not have a real freeze window — and forcing one produces a backlog that takes three weeks to clear.&lt;/p&gt;

&lt;p&gt;In that situation a phased cutover is better, even though it is more expensive and more complex: you run two systems in parallel for a defined period, with a written rule for which is authoritative for each transaction type. Nobody enjoys it. It beats a weekend plan that assumes a stoppage the business cannot deliver, and choosing between the two shapes is a conversation worth having with an &lt;a href="https://www.mastersoftwaresolutions.com/erp-consulting-services/" rel="noopener noreferrer"&gt;ERP consulting&lt;/a&gt; team before the date is set rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one artefact that matters
&lt;/h2&gt;

&lt;p&gt;Build the runbook as a shared document with a checkbox and a timestamp field per step, and make one person responsible for ticking them in real time.&lt;/p&gt;

&lt;p&gt;Not because the ticks matter. Because at 3pm Sunday, when someone asks whether opening balances were posted before or after the inventory load, the answer has to be a fact rather than a recollection.&lt;/p&gt;

</description>
      <category>erp</category>
      <category>odoo</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>Making Odoo Bilingual for Quebec: Where EN/FR Actually Breaks</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:52:49 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/making-odoo-bilingual-for-quebec-where-enfr-actually-breaks-2o0b</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/making-odoo-bilingual-for-quebec-where-enfr-actually-breaks-2o0b</guid>
      <description>&lt;p&gt;Activating French in Odoo takes about ninety seconds. Settings → Translations → Languages, tick &lt;code&gt;fr_CA&lt;/code&gt;, save. The menus flip, the buttons flip, and everyone agrees the Quebec problem is solved.&lt;/p&gt;

&lt;p&gt;Then someone in Montréal opens an invoice PDF. The header is French. The line items say &lt;code&gt;Stainless Steel Bracket, 40mm&lt;/code&gt;. The payment-terms footer is English. The email that delivered it was subject-lined "Your invoice is ready."&lt;/p&gt;

&lt;p&gt;Quebec's Charter of the French Language is not interested in your menus. &lt;a href="https://www.legisquebec.gouv.qc.ca/en/version/cs/C-11?code=se:57" rel="noopener noreferrer"&gt;Section 57&lt;/a&gt; says invoices, receipts, acquittances and other documents of the same nature must be drawn up in French, and that you may not send one in another language unless the French version is available on terms at least as favourable. &lt;a href="https://www.legisquebec.gouv.qc.ca/en/version/cs/C-11?code=se:52_1" rel="noopener noreferrer"&gt;Section 52.1&lt;/a&gt; says computer software must be available in French unless no French version exists. The obligations that bite are about the artifacts your ERP emits and the interface your Quebec staff sit in front of — not the language dropdown in the corner of the screen.&lt;/p&gt;

&lt;p&gt;Here is what actually has to be fixed, in the order the fixes tend to be needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install &lt;code&gt;fr_CA&lt;/code&gt;, not &lt;code&gt;fr_FR&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This sounds pedantic and it is not. &lt;code&gt;fr_FR&lt;/code&gt; gives you &lt;code&gt;DD/MM/YYYY&lt;/code&gt;, a comma decimal separator and a non-breaking-space thousands separator. Canadian French convention differs, and more importantly the vocabulary differs — Odoo's &lt;code&gt;fr_CA&lt;/code&gt; translation catalogue uses the terminology Quebec accountants and the OQLF expect, where &lt;code&gt;fr_FR&lt;/code&gt; will happily print terms that read as European.&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="c1"&gt;# check what you've actually got active
&lt;/span&gt;&lt;span class="n"&gt;env&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.lang&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;active&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;True&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;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ['en_US', 'fr_CA']  ← what you want
# ['en_US', 'fr_FR']  ← what half the instances I've opened actually have
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you inherited an instance with &lt;code&gt;fr_FR&lt;/code&gt; installed and data already translated against it, switching is not a checkbox. The translated values are keyed by language code, so you are looking at a migration of every translated field, not a settings change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The translations are not where the old tutorials say they are
&lt;/h2&gt;

&lt;p&gt;If you learned Odoo before version 16, you know &lt;code&gt;ir_translation&lt;/code&gt; — one row per translated term, queryable, easy to audit. It's gone. Since &lt;a href="https://github.com/odoo/odoo/pull/97692" rel="noopener noreferrer"&gt;this change&lt;/a&gt;, translated fields are stored as JSONB in the column itself. A product name is no longer a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en_US"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stainless Steel Bracket, 40mm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fr_CA"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Support en acier inoxydable, 40 mm"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a much better design and it breaks every audit script written against the old model. The useful consequence is that you can find your gaps in one query:&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;id&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;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'en_US'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;english&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;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'fr_CA'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;french&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;product_template&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'fr_CA'&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt;  &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'fr_CA'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'en_US'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against &lt;code&gt;product_template&lt;/code&gt;, &lt;code&gt;product_category&lt;/code&gt;, &lt;code&gt;account_tax&lt;/code&gt;, &lt;code&gt;account_payment_term&lt;/code&gt; and any custom model with a &lt;code&gt;translate=True&lt;/code&gt; field. On a catalogue of a few thousand SKUs the result is usually humbling. Product names are the single largest source of English text on a French invoice, and no amount of interface translation touches them.&lt;/p&gt;

&lt;p&gt;Most teams find this at UAT, which is late — the ordering problem is that the catalogue has to be translated before the data load, not after, and it's the kind of sequencing that &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;specialists who handle ERP rollouts&lt;/a&gt; like this build into the migration plan rather than discovering in week nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;res.partner.lang&lt;/code&gt; is the switch that matters
&lt;/h2&gt;

&lt;p&gt;Odoo decides what language to speak to a customer based on the &lt;code&gt;lang&lt;/code&gt; field on the contact — not the language of the user who clicked the button. This is the field that governs invoice PDFs, quotation PDFs, portal pages and automated emails. It is also, in a freshly migrated database, almost always set to whatever the default was at import time.&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="n"&gt;qc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&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="nf"&gt;search&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;state_id.code&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;QC&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;lang&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fr_CA&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="n"&gt;qc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lang&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;fr_CA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not run that blind. Province is a proxy for language preference, not the thing itself — plenty of Quebec-registered businesses correspond in English and will tell you so. What the query is genuinely good for is producing the list of contacts a human then has to make a decision about. In practice the sales team owns that list, and the field gets set as part of customer onboarding from then on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reports render in the language you pass them
&lt;/h2&gt;

&lt;p&gt;A QWeb report does not automatically pick up the partner's language. You set it, either in the template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;t&lt;/span&gt; &lt;span class="na"&gt;t-call=&lt;/span&gt;&lt;span class="s"&gt;"web.html_container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;t&lt;/span&gt; &lt;span class="na"&gt;t-foreach=&lt;/span&gt;&lt;span class="s"&gt;"docs"&lt;/span&gt; &lt;span class="na"&gt;t-as=&lt;/span&gt;&lt;span class="s"&gt;"doc"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;t&lt;/span&gt; &lt;span class="na"&gt;t-set=&lt;/span&gt;&lt;span class="s"&gt;"lang"&lt;/span&gt; &lt;span class="na"&gt;t-value=&lt;/span&gt;&lt;span class="s"&gt;"doc.partner_id.lang or 'en_US'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;t&lt;/span&gt; &lt;span class="na"&gt;t-call=&lt;/span&gt;&lt;span class="s"&gt;"web.external_layout"&lt;/span&gt; &lt;span class="na"&gt;t-lang=&lt;/span&gt;&lt;span class="s"&gt;"lang"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/t&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/t&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/t&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or at the call site:&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="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;account.account_invoices&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partner_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;_render_qweb_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;account.report_invoice_with_payments&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard accounting reports handle this correctly. Custom report templates written by someone in a hurry frequently do not, and the failure is silent — the PDF renders, it just renders in English. If you have custom reports, this is the first place to look.&lt;/p&gt;

&lt;p&gt;Payment terms and the invoice footer deserve their own mention because they are separate records with their own translations, and a French invoice with English terms and conditions is exactly the artifact section 57 is about. Contracts of adhesion carry a further requirement — the French version must be presented first — which &lt;a href="https://www.dlapiper.com/en-gb/insights/publications/2025/06/quebecs-language-laws-changed-this-week" rel="noopener noreferrer"&gt;DLA Piper's summary of the June 2025 changes&lt;/a&gt; covers in more detail than I will here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mail templates need to be told, too
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;field&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"lang"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ object.partner_id.lang }}&lt;span class="nt"&gt;&amp;lt;/field&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that line the template renders in the language of the user or cron job that triggered it. Sales order confirmations, invoice delivery and payment reminders are the three that customers see most, and all three ship with the expression set correctly in core — so this is a problem you create, not one you inherit. Check every &lt;code&gt;mail.template&lt;/code&gt; your custom modules add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your custom module has no French until you ship a &lt;code&gt;.po&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Strings in your own code are untranslated until extracted. Wrap them, export, translate, commit to &lt;code&gt;my_module/i18n/fr_CA.po&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;odoo-bin &lt;span class="nt"&gt;-d&lt;/span&gt; mydb &lt;span class="nt"&gt;--i18n-export&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fr_CA.po &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;--modules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my_module &lt;span class="nt"&gt;--language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fr_CA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extraction gotcha that catches everyone at least once:&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="c1"&gt;# NOT extracted — the f-string is evaluated before _() sees it
&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing tax on line &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# extracted correctly
&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;Missing tax on line %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship the &lt;code&gt;.po&lt;/code&gt; file in the module. Translating through the UI writes to the database, which means it does not survive a rebuild and does not exist in your other environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;None of this makes your instance compliant, and it would be dishonest to imply otherwise. It makes it &lt;em&gt;capable&lt;/em&gt; of being compliant. Somebody with real French still has to write the product descriptions, because machine-translated SKU names read as machine-translated to a Quebec customer, and a bad French translation is worse for the relationship than an English one. Budget the translation work as translation work — a per-string cost with a human in the loop — not as a configuration task.&lt;/p&gt;

&lt;p&gt;Two more things sit outside Odoo entirely: OQLF registration for employers with 25 or more staff, and the question of whether a heavily customised self-hosted instance makes &lt;em&gt;you&lt;/em&gt; the software vendor for section 52.1 purposes. Ask a Quebec-qualified lawyer, not your systems integrator, and certainly not me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the test a lawyer would run
&lt;/h2&gt;

&lt;p&gt;Create a user with &lt;code&gt;lang = fr_CA&lt;/code&gt; and no English fallback habit. Have them raise a quotation for a Quebec customer, confirm it, invoice it, and email the invoice. Then open every PDF and every email and read them as a customer in Montréal would — line items, tax labels, payment terms, footer, email subject and body.&lt;/p&gt;

&lt;p&gt;Anything still in English is a finding. That takes twenty minutes and tells you more than any compliance checklist.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Big-Bang vs Phased ERP: Which Actually Fits a Canadian Mid-Market Rollout</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Thu, 27 Aug 2026 10:39:44 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/big-bang-vs-phased-erp-which-actually-fits-a-canadian-mid-market-rollout-2o8d</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/big-bang-vs-phased-erp-which-actually-fits-a-canadian-mid-market-rollout-2o8d</guid>
      <description>&lt;p&gt;Ask a CFO whether to go big-bang or phased and you get a budget answer. Ask an engineer and you get the right one, because this is an integration-architecture decision wearing a finance costume.&lt;/p&gt;

&lt;p&gt;The question is not "how much disruption can we absorb." It is "how many systems of record am I willing to have at once, and for how long." Every other trade-off falls out of that.&lt;/p&gt;

&lt;h2&gt;What phasing actually costs you technically&lt;/h2&gt;

&lt;p&gt;The pitch for phased is that risk goes down because you change less at a time. That is true of user-facing risk and false of data risk.&lt;/p&gt;

&lt;p&gt;Say you go live with Odoo Accounting and Inventory in March, and defer Sales and Purchase to September. For six months, sales orders live in the legacy system and stock lives in Odoo. Which means you now own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An interface pushing legacy orders into Odoo as &lt;code&gt;stock.picking&lt;/code&gt; records, or a nightly reservation sync&lt;/li&gt;
&lt;li&gt;A reverse interface returning available quantities so legacy quoting does not oversell&lt;/li&gt;
&lt;li&gt;A reconciliation process, because those two will drift&lt;/li&gt;
&lt;li&gt;Two places where a user can change a unit price, and a rule about which one wins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a lower-risk configuration than a big-bang. It is a lower-&lt;em&gt;change&lt;/em&gt; configuration with a distributed-systems problem bolted on for two quarters. Anyone who has maintained a temporary integration knows how the word "temporary" performs under load.&lt;/p&gt;

&lt;p&gt;The dependency graph is the thing to look at. In Odoo, &lt;code&gt;sale&lt;/code&gt; depends on &lt;code&gt;stock&lt;/code&gt; for delivery and on &lt;code&gt;account&lt;/code&gt; for invoicing; &lt;code&gt;mrp&lt;/code&gt; depends on &lt;code&gt;stock&lt;/code&gt; and &lt;code&gt;purchase&lt;/code&gt;. Cut the phase boundary across one of those edges and you are hand-rolling what the framework gives you for free.&lt;/p&gt;

&lt;h2&gt;The dual-write problem, concretely&lt;/h2&gt;

&lt;p&gt;Any phase boundary that leaves inventory in one system and order intake in another is a dual-write. You will need a reconciliation job, and you should write it before go-live rather than in week three when the numbers stop matching.&lt;/p&gt;

&lt;p&gt;Something roughly this shape, against Odoo's external API:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import xmlrpc.client, csv
from collections import defaultdict

URL, DB, USER, KEY = "https://erp.example.ca", "prod", "svc_recon", "..."

common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common")
uid = common.authenticate(DB, USER, KEY, {})
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")

quants = models.execute_kw(DB, uid, KEY, "stock.quant", "search_read",
    [[["location_id.usage", "=", "internal"]]],
    {"fields": ["product_id", "quantity", "reserved_quantity"]})

odoo_qty = defaultdict(float)
for q in quants:
    odoo_qty[q["product_id"][1]] += q["quantity"] - q["reserved_quantity"]

with open("legacy_available.csv") as fh:
    for row in csv.DictReader(fh):
        drift = odoo_qty.get(row["sku"], 0.0) - float(row["available"])
        if abs(drift) &amp;gt; 0.001:
            print(f"{row['sku']}\tdrift={drift:+.3f}")
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Twenty lines, and it runs nightly for six months. Fine. The point is that it exists at all, that someone has to read its output every morning, and that a big-bang rollout does not need it.&lt;/p&gt;

&lt;p&gt;Note &lt;code&gt;reserved_quantity&lt;/code&gt; in there. Half the drift you will chase during a phased rollout is reservation semantics — the legacy system's idea of "committed" and Odoo's idea of "reserved" are not the same concept, and reconciling on &lt;code&gt;quantity&lt;/code&gt; alone will have you hunting ghosts.&lt;/p&gt;

&lt;h2&gt;What big-bang actually costs you&lt;/h2&gt;

&lt;p&gt;One weekend, and no rollback worth the name.&lt;/p&gt;

&lt;p&gt;The rollback story is where big-bang is genuinely weaker, and I would not let anyone talk past it. Once Monday's transactions are in the new system, going back means either replaying them into the legacy database or losing them. In practice, by Monday afternoon you are committed. That is a real risk and it is not eliminated by good planning, only reduced.&lt;/p&gt;

&lt;p&gt;What good planning does eliminate is the &lt;em&gt;reason&lt;/em&gt; you would want to roll back. A two-day parallel run — same week's transactions through both systems, outputs reconciled line by line — is what turns cutover weekend from a leap into a rehearsal. Projects that skip it are the ones with rollback conversations.&lt;/p&gt;

&lt;h2&gt;The honest decision rule&lt;/h2&gt;

&lt;p&gt;For Canadian companies under about 250 staff, on a single ERP suite, my position is fairly firm:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase by site or legal entity. Do not phase by module.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Phasing by site works because each site is a complete system of record for itself — one plant goes live fully on Odoo, the other keeps its legacy stack fully, and the only interface between them is the consolidation you needed anyway. Phasing by module works only when the modules are genuinely decoupled, and inside a single ERP suite they are decoupled by design &lt;em&gt;inside the system&lt;/em&gt; and coupled by process outside it.&lt;/p&gt;

&lt;p&gt;There is one module-level phase boundary I do think is safe, and it is HR and payroll. Payroll's coupling to the rest of the ERP is one journal entry per run. Deferring it is cheap, and the timing argument in Canada is strong: T4 information returns are due the &lt;a href="https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/payroll/file-information-returns-slip-summaries/when-to-file.html" rel="noopener noreferrer"&gt;last day of February&lt;/a&gt;, so switching payroll systems in Q1 buys you a filing cycle of grief for no benefit. Take payroll live in July.&lt;/p&gt;

&lt;h2&gt;Two Canadian wrinkles that change the maths&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-province tax makes big-bang more attractive, not less.&lt;/strong&gt; If you sell into five provinces, your tax determination logic has to be right in one place. Running it in two systems for two quarters, with GST/HST/PST/QST mappings maintained twice, is exactly the kind of duplicated configuration that produces a filing correction. Tax is not a good candidate for a temporary interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version support pressure is real.&lt;/strong&gt; Odoo maintains only the three most recent major versions, and Odoo 19 shipped in October 2025. A phased rollout that starts on Odoo 19 in early 2026 and finishes in mid-2027 will spend its second half on a version approaching the edge of that window, with an upgrade landing on top of a half-finished implementation. Compressing the timeline is worth something on that basis alone, and it is the argument I would actually make to a CFO. Version-window pressure belongs in the architecture conversation rather than the support contract, and &lt;a href="https://www.mastersoftwaresolutions.com/odoo-implementation-services/" rel="noopener noreferrer"&gt;an experienced Odoo implementation team&lt;/a&gt; will raise it during scoping instead of in year two. If nobody on your shortlist brings it up, ask why.&lt;/p&gt;

&lt;h2&gt;Where the budget argument comes back in&lt;/h2&gt;

&lt;p&gt;Panorama's &lt;a href="https://www.panorama-consulting.com/panorama-consulting-group-releases-latest-study-of-erp-implementation-outcomes-across-the-globe/" rel="noopener noreferrer"&gt;2026 ERP Report&lt;/a&gt; found more than a quarter of organizations exceeded their project budgets, with additional technology needs the leading cause. Temporary integrations are exactly that: additional technology, procured mid-project, justified as short-term, maintained for years. Every phase boundary is a small chance of a permanent middleware line item.&lt;/p&gt;

&lt;h2&gt;The checklist&lt;/h2&gt;

&lt;p&gt;Choose big-bang if most of these are true: single site, or multiple sites on one process model. Under 250 staff. One legacy accounting system rather than three. A slow quarter you can cut over into. Enough internal bandwidth for a two-day parallel run.&lt;/p&gt;

&lt;p&gt;Choose phased if: multiple legal entities or genuinely different operating models per site; a site you can treat as a pilot without cross-site inventory movement; or a hard external constraint — an audit, an acquisition, a customer EDI deadline — that fixes one date and lets the rest move.&lt;/p&gt;

&lt;p&gt;If you find yourself phasing because the project feels too big to do at once, that is not an architecture reason. That is a resourcing reason, and it is better solved by adding people to the discovery phase than by adding an integration you will still be maintaining in 2028.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Roast loss breaks your bill of materials: how to evaluate an ERP partner for a growing roastery</title>
      <dc:creator>Mohit</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:55:13 +0000</pubDate>
      <link>https://dev.to/mohit_777aded202b4d39397d/roast-loss-breaks-your-bill-of-materials-how-to-evaluate-an-erp-partner-for-a-growing-roastery-4a3b</link>
      <guid>https://dev.to/mohit_777aded202b4d39397d/roast-loss-breaks-your-bill-of-materials-how-to-evaluate-an-erp-partner-for-a-growing-roastery-4a3b</guid>
      <description>&lt;p&gt;Every ERP demo I have watched a coffee roaster sit through has the same silent failure point. The salesperson builds a bill of materials on screen: 10 kg of green Colombian in, 10 kg of roasted Colombian out. Nobody in the room says anything, because the number looks right.&lt;/p&gt;
 

&lt;p&gt;It isn't. A light roast dropped at first crack loses around 10% of its green weight; push well past second crack and you can lose &lt;a href="https://royalcoffee.com/understanding-roast-yield-and-loss/" rel="noopener noreferrer"&gt;as much as 25%&lt;/a&gt;. Most of that is water — green coffee carries 10–12% moisture — and the rest is organic matter burned off in the drum. The loss is not a constant, it is not a rounding error, and it changes with the roast profile, the ambient humidity and the lot.&lt;/p&gt;
 

&lt;p&gt;That single unmodelled fact is where roastery ERP projects start to rot. Inventory drifts, cost of goods is wrong by a margin that grows with volume, and within six months someone is maintaining a shadow spreadsheet to reconcile what the system says against what is actually on the pallet. So when a roaster asks me what makes a good ERP partner, I don't answer with a list of certifications. I answer with three things to make them do in front of you.&lt;/p&gt;
 

&lt;h2&gt;Test one: make them model a yield that changes every batch&lt;/h2&gt;
 

&lt;p&gt;Ask the partner to build a roast in a sandbox, then produce it at a different quantity than the one planned. Watch what the system does with the difference.&lt;/p&gt;
 

&lt;p&gt;There are only a few defensible answers, and a good partner will tell you which one they're choosing and why:&lt;/p&gt;
 

&lt;p&gt;&lt;strong&gt;Capture actual output at close.&lt;/strong&gt; The manufacturing order plans 10 kg and you confirm 8.3 kg produced. The variance flows to a shrinkage or yield-loss account rather than sitting as phantom stock. In Odoo terms, this is the gap between &lt;code&gt;product_qty&lt;/code&gt; on the manufacturing order and what actually gets recorded as produced — cheap to run, honest, and it gives you a variance report you can chart by roaster, profile and origin.&lt;/p&gt;
 

&lt;p&gt;&lt;strong&gt;Model loss explicitly as a by-product.&lt;/strong&gt; Useful if you want chaff and moisture loss visible as its own line, or if you're allocating loss cost across a blend. More setup, more discipline required from the person closing the order.&lt;/p&gt;
 

&lt;p&gt;&lt;strong&gt;Two units of measure on the same product.&lt;/strong&gt; Green kilos and roasted kilos as separate UoMs with a conversion factor. This is the answer that sounds elegant in a demo and hurts later, because the factor is not fixed. If a partner reaches for this first, they have not run a roastery.&lt;/p&gt;
 

&lt;p&gt;The tell isn't which option they pick. It's whether they ask you for your actual yield history before picking. A partner who wants twelve months of green-in/roasted-out numbers before touching the configuration is doing the job. One who says "we'll handle that with a custom field" is deferring the problem to your accountant.&lt;/p&gt;
 

&lt;h2&gt;Test two: ask them to trace one retail bag backwards&lt;/h2&gt;
 

&lt;p&gt;Pick a 250 g bag off your own shelf. Ask the partner to show you, in their system, the path from that bag to the specific green lot it came from — through the blend, the roast batch, the packing run and the wholesale order it shipped on.&lt;/p&gt;
 

&lt;p&gt;This is where lot genealogy either exists or doesn't. In Odoo the mechanics are documented and unglamorous: you enable Lots &amp;amp; Serial Numbers in the Inventory settings, switch each product's inventory tracking &lt;a href="https://www.odoo.com/documentation/18.0/applications/inventory_and_mrp/manufacturing/workflows/manufacture_lots_serials.html" rel="noopener noreferrer"&gt;from quantity to lots&lt;/a&gt;, and the manufacturing order then carries a lot number for the finished coffee alongside the component lots it consumed. Blends make it harder, because one finished lot legitimately descends from three or four green lots at once, and a partner who models a blend as a single flattened component has just destroyed the audit trail you'll need.&lt;/p&gt;
 

&lt;p&gt;You will need it. If you sell into the EU, the Deforestation Regulation applies to coffee from &lt;a href="https://environment.ec.europa.eu/topics/forests/deforestation/regulation-deforestation-free-products_en" rel="noopener noreferrer"&gt;30 December 2026 for large and medium operators and 30 June 2027 for micro and small ones&lt;/a&gt;, and compliance means plot-level geolocation data and a due diligence statement reference attached to the goods you place on the market. The practical question for your ERP is whether that data lives as a structured field on the green lot record — inherited automatically by every downstream roast, blend and finished lot — or as a PDF in a shared drive that someone has to go find during an audit.&lt;/p&gt;
 

&lt;p&gt;Ask the partner where the DDS reference number is stored. If they haven't heard of it, they can still learn it. If they wave it away as a compliance detail to sort out later, they will be building you a system that cannot answer the one question a regulator asks.&lt;/p&gt;
 

&lt;h2&gt;Test three: hand them your wholesale price book and your subscription list at the same time&lt;/h2&gt;
 

&lt;p&gt;A growing roastery is two businesses sharing one warehouse. Wholesale runs on negotiated per-kilo pricing, standing weekly orders, 30-day terms and pallets. Direct-to-consumer runs on retail pricing, recurring subscriptions, grind-on-demand and single bags going out through a Shopify or WooCommerce storefront. The same green coffee feeds both, and the two channels compete for it.&lt;/p&gt;
 

&lt;p&gt;Most demos handle one of these beautifully and fake the other. So give the partner both at once and ask a specific question: when a wholesale account's standing order and Friday's subscription run both need the last 40 kg of the Ethiopian, what does the system do?&lt;/p&gt;
 

&lt;p&gt;Watch also for how they answer the grind question, because it's the decision that quietly shapes your inventory forever. Whole bean, filter grind and espresso grind can be product variants on one template, or three separate SKUs, or a post-pick operation that doesn't touch stock at all. Each choice is defensible; each one changes how you count, forecast and reorder for the next decade. A good partner walks you through the trade-off in plain language and makes you decide. When we're brought in as an &lt;a href="https://www.mastersoftwaresolutions.com/odoo-partner/" rel="noopener noreferrer"&gt;Odoo implementation partner&lt;/a&gt; on a stalled rollout, that decision is very often the thing that was never actually made — it was configured by default and then worked around.&lt;/p&gt;
 

&lt;h2&gt;What the good ones do in week one&lt;/h2&gt;
 

&lt;p&gt;They don't configure anything. They map your process — green intake, sample roasting, production roasting, resting, blending, packing, wholesale fulfilment, e-commerce fulfilment — and they find the three places where your current process only works because a specific person remembers something.&lt;/p&gt;
 

&lt;p&gt;They also argue with your requirements list. Panorama Consulting's &lt;a href="https://www.panorama-consulting.com/panorama-consulting-group-releases-latest-study-of-erp-implementation-outcomes-across-the-globe/" rel="noopener noreferrer"&gt;2026 ERP Report&lt;/a&gt; found more than a quarter of organisations exceeded their project budget, with additional technology needs the leading cause, and pointed at "fatal misfits" discovered late as the pattern behind it. Late discovery is a scoping failure, not a software failure. The partner who tells you in week one that four of your twenty must-haves are actually process problems is saving you the overrun.&lt;/p&gt;
 

&lt;h2&gt;The part nobody puts in the pitch deck&lt;/h2&gt;
 

&lt;p&gt;A partner who works this way is slower to start and more expensive than one who will just install and hand you the keys. You will spend three or four weeks on discovery that produces no visible software. If you are roasting fifty bags a month, that cost is real and the honest answer may be that a spreadsheet plus your storefront is still the right stack — ERP earns its keep somewhere around the point where two people can no longer hold the whole operation in their heads.&lt;/p&gt;
 

&lt;p&gt;And no ERP will improve your coffee. It will tell you what a batch cost, which origin actually carries your margin, and whether the Kenyan is worth the freight. Those are finance questions, not cupping questions.&lt;/p&gt;
 

&lt;p&gt;If you're evaluating partners now, take three things into the next call: twelve months of green-in and roasted-out weights, your wholesale price book, and one retail bag. Ask them to reconcile the first, price the second, and trace the third. The ones who can't will tell you so in the first twenty minutes, which is exactly what you want from a demo.&lt;/p&gt;

</description>
    </item>
    <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>
