Case study · E-commerce and marketplaces

The hidden bug in MercadoLibre Full: how a discovery during an agile refinement redefined replenishment in Odoo

A real e-commerce case, anonymized. An automotive spare-parts distributor operating on MercadoLibre Full asked us to automate their replenishment. What started as a button in Odoo ended up being the discovery of a many-to-many relationship that breaks the common sense of almost anyone who sells on marketplaces with consolidated logistics. The addon stayed with the client; the method stays with Transgenia.

Commerce on marketplaces with consolidated logistics, MercadoLibre Full in Latin America, Amazon FBA in North America, has a deceptively simple surface. To the commercial team the problem looks operational: loading quantities by hand into the platform is slow and error-prone. To the technical team the problem looks like an integration issue: if there is an API, you just call it from Odoo and that is it. Both diagnoses are consequences of the same real bottleneck, but neither of them describes it.

The bottleneck lives in an assumption that almost everyone inherits without questioning: one SKU = one listing. When that assumption breaks, and on MercadoLibre Full it always breaks, the replenishment design breaks with it, silently, in the form of intermittent stockouts and purchase orders that "for some reason" always fall short.

This text reconstructs how we reached that conclusion during an agile refinement with a client we no longer serve, how we designed an Odoo addon that replaces manual replenishment with a deterministic engine without touching Odoo's native magic, and why the resulting pattern is replicable to any marketplace with consolidated fulfillment.

1. When common sense misleads you: the hidden bug in MercadoLibre Full

The first meeting with the client started with a crisp operational request: they wanted a button inside Odoo that would push stock to MercadoLibre. The distributor's internal team had already built two spreadsheets with the replenishment algorithm; they wanted to "move it into Odoo" and have Odoo do the work. Everything else (data model, publishing on the marketplace, approval sequence) was taken for granted.

It was not. At one point during the refinement, on a shared screen displaying the client's Excel file, the exchange that redefined the project took place:

"I was thinking that MercadoLibre's Order ID was the listing number, and that we could nest all those products inside that Order ID so that, when the skeleton is generated, MercadoLibre would update the stock."

"We need to be very clear with the names here, because we can get horribly confused. In MercadoLibre, the Order ID is a sale, not a listing. What you are thinking of is an Item ID: that one is a listing. And a listing has only one product, but the same product can have a hundred listings. It is a one-to-many relationship."

This dialogue (anonymized; the meeting used real names) condenses the underlying finding. The Order ID identifies a consummated sale; the Item ID identifies a published ad. They are distinct entities with distinct lifecycles, and MercadoLibre allows the same SKU to appear in different listings with independent prices, shipping types and stock. Before that conversation, the project was on its way to modeling a Many2one between listing and product and designing the interface around that assumption. It would have worked in development; it would have failed in production on the very first supplier purchase that came up short.

The lesson is not "read MercadoLibre's documentation more carefully", that distinction has been there in the seller glossary for years. The lesson is more uncomfortable: information biases are not corrected by documentation, they are corrected by conversation. Agile refinement is not a ritual for people who practice Scrum; it is where most of the risk in an Odoo project actually lives.

2. Why it is not "one product = one listing"

In MercadoLibre, a product is what exists in the physical world: a part, with a SKU, a cost, a storage location. A listing is what exists on the platform: an ad with title, photos, description, price, condition (new, used), shipping type (Full, Flex, Colecta) and assigned stock. In Odoo, the first one is product.product; the second is a foreign entity that the platform numbers with its own sequence (the Item ID).

The real cardinality is one-to-many from the product side: the same SKU can live in several listings simultaneously. In an automotive spare-parts business, where the catalog runs into thousands of parts and competition for search position is fierce, having several listings of the same product is not an accident: it is deliberate commercial strategy. One listing may carry the anchor price; another, the variant with subsidized shipping; another, a bundle with a complementary product; another, the leading MELI channel listing that only accepts certain parameters.

flowchart LR
  P((Unique SKU
e.g. oil filter XYZ)) L1[Listing 1
Full shipping
Anchor price] L2[Listing 2
Full shipping
MELI leader] L3[Listing 3
Full shipping
Bundle with complementary] L4[Listing 4
Colecta
Other channel] S1[Full stock assigned 1] S2[Full stock assigned 2] S3[Full stock assigned 3] S4[Master warehouse stock] P --> L1 P --> L2 P --> L3 P --> L4 L1 --> S1 L2 --> S2 L3 --> S3 L4 --> S4

Each of those listings behaves like a virtual outlet with its own demand: it accumulates visits, questions, sales and rotation velocity separately. MercadoLibre Full aggravates the asymmetry: for every listing with Full shipping, the platform requires stock assigned to that specific listing, not to the general SKU. The practical consequence is that, when it is time to calculate how much to order from the supplier, typically a manufacturer in China with long lead times, demand must be aggregated by SKU; but when it is time to decide how much to ship to Full, it must be disaggregated by listing.

Any replenishment engine that treats these two calculations as the same problem starts accumulating error. A small error, hard to trace, that manifests as the intermittent stockout that daily operations "solve" by placing a rush order with the supplier every so often, the classic symptom of a badly calibrated model assumption.

3. Agile refinement: the conversation that uncovered the bug

In the software development community, refinement is the practice of breaking down requirements into executable units through short, successive conversations, each centered on a concrete artifact both parties can look at simultaneously. It is used little in Odoo projects. The implicit assumption is that Odoo, being a configurable product, eliminates the need for refinement: it is enough to "receive the client's requirement" and translate it to Studio, server actions or a custom module. That assumption is expensive.

The meeting that uncovered the many-to-many between SKU and listing was not a two-hour discovery session. It was a short sprint, one hour, minimal agenda, an Excel file projected on screen, in which we asked what each column, each formula, each tab did. In one of those questions, the operational person on the client side (who actually knew the file) said it with complete ease:

"It is good to have these conversations with you or with whoever he points to, because those details are things he does not sit down to explain. He just throws the stuff at you and tells you to get it done."

The sponsor had handed over the Excel taking for granted that the algorithm was self-contained and that a developer could just transplant it. The operational person knew otherwise, but no one had asked her before. Refinement served here less to "collect requirements" and more to discover that the mental model on one side did not match the model on the other side, and that both models were coexisting in the project without ever having been made explicit.

The many-to-many finding arrived at minute twenty of that meeting. It was not written in any prior brief. In a linear process it would have arrived late and expensive, probably during the acceptance testing round, when there would already have been code, migrations, training and expectations aligned to the wrong model. Instead it arrived as a twenty-minute conversation with two people looking at the same spreadsheet.

The transferability of this lesson to the rest of the project shows best through an operational rule: in Odoo implementations, "following the Excel algorithm" is not enough. You have to translate the mental model before translating it to Python. Refinement is not a methodological accessory; it is where the risk lives.

4. Anatomy of replenishment: ABCD classification by cumulative sales value

Once the model problem is solved (SKU and listing are distinct entities with one-to-many cardinality), the calculation problem arrives. With thousands of parts in the catalog, not all of them deserve the same level of attention: some sell all day, others hardly ever, and a third group is there as part of the catalog but has not moved for months. Treating them all equally is the mistake that produces two symptoms at once, capital trapped in dead product and stockouts in the live product.

ABC classification is a classic logistics tool; its contribution is simple but counterintuitive for anyone who has never used it: you classify by cumulative sales value, not by units sold. A product that sells five units a month at five thousand pesos contributes more to the business than one that sells a hundred units a month at fifty. The first rule of serious marketplace replenishment is to measure in money, not in pieces.

In this project we added the letter D. Family A concentrates roughly 70% of cumulative sales value, family B the next 20%, and family C the remaining 10%. Family D is the operational exception: products that did not sell in the evaluated period. The distinction matters because a sales report can carry noise, parts with no real movement that appear due to data-entry errors or internal transfers, and those products must stay out of the calculation, not end up in family C with a positive minimum that forces an unjustified purchase.

pie showData
  title Distribution of sales VALUE by family
  "Family A (70% of value)" : 70
  "Family B (20% of value)" : 20
  "Family C (10% of value)" : 10

The recalculation runs once a month through a dedicated cron. Products naturally migrate between families over time, and that migration is a business signal: a part moving from B to A deserves the manager's review because it is probably holding up the quarter; a part moving from A to D is an early warning of discontinuation. Technically the family_id field lives on product.template, not on product.product, an important decision we discuss in the section on design errors: sales are grouped by the main product, not by variant.

The monthly cron itself follows a compact and auditable pipeline: read the sales report for the period, group by main product, calculate the total value per SKU, sort from largest to smallest, accumulate up to the 70%, 90% and 100% cutoffs, assign the family and persist to product.template.family_id. Products that appear in the catalog but not in the sales report land in family D. This last point is not a minor detail: it is what prevents catalog debris from consuming purchase capital.

flowchart LR
  A([Monthly cron]) --> B[Read sale.report
last N months] B --> C[Group by
product.template] C --> D[Sum sales value
qty x unit_price] D --> E[Sort
largest to smallest] E --> F{Cumulative
cutoff} F -->|up to 70%| A2[Family A] F -->|71% - 90%| B2[Family B] F -->|91% - 100%| C2[Family C] F -->|no sales| D2[Family D] A2 --> Z[(product.template.family_id)] B2 --> Z C2 --> Z D2 --> Z

With the classification current, the system has what it needs to put the levers where they win: tighten minimums on family C, generate slack in family A, keep family D flagged as out of the replenishment calculation until it moves again. Products migrate naturally between families over time, and that migration is a business signal: a part moving from B to A deserves the manager's review because it is probably holding up the quarter; a part moving from A to D is an early warning of discontinuation. Technically the family_id field lives on product.template, not on product.product, an important decision we discuss in the section on design errors: sales are grouped by the main product, not by variant.

5. Weighted Daily Demand: why December and January weigh more

Classifying products into families is not enough; you also have to estimate how much each of them will sell in the upcoming period, and do it with reasonable precision given the material available: twelve to twenty-four months of historical sales per listing and per SKU. Weighted Daily Demand (WDD) is the engine that translates that history into an operational number. The idea is simple: take the daily sales average of each historical month, multiply by a configurable weight, add up and normalize. The conceptual formula reads:

WDD = Σ (sales_of_month × weight_of_month) / Σ (weight_of_month × days_of_month)

The keyword is configurable weight. In a Mexican automotive business, December and January are not any month: December concentrates purchases driven by year-end bonuses (aguinaldo) and gifting, January absorbs the rebound of "the pending fix" that people postponed all year. A simple unweighted average buries that seasonality and produces a minimum that is too low for the high season and a maximum that is too high for the low season. Weights are system parameters, not hardcoded constants; the manager adjusts them when the business changes, an early campaign, a customs problem, a channel migration.

WDD combines with two more family-level parameters to produce the minimum/maximum pair: days of minimum stock and days of maximum stock. Multiplying WDD by those days yields quantities in units. A family A product typically carries a higher minimum, capital tied up is accepted in exchange for not breaking stock on the bestseller; a family C product carries tight minimums to avoid loading inventory with slow-moving parts. The days-per-family configuration is defined once and rules the entire catalog; further per-product personalization is intentionally absent because it would open the door to inconsistent criteria.

flowchart LR
  H[(Sales history
by month)] --> V1[Jan sales] H --> V2[Feb sales] H --> V3[...] H --> V12[Dec sales] P[(Configurable weights
by month)] --> M[Weighting
sales_month x weight_month] V1 --> M V2 --> M V3 --> M V12 --> M M --> S[Weighted sum] P --> N[Normalization
sum weight x days] S --> DDP[[Weighted Daily
Demand]] N --> DDP F[(ABCD family
min and max days)] --> R[Min = WDD x days_min
Max = WDD x days_max] DDP --> R R --> O[(stock.warehouse.orderpoint
product_min_qty product_max_qty)]

6. The two twin algorithms and why they are linked

An inevitable architectural consequence flows from the many-to-many relationship between SKU and listing: two calculations are needed, not one. The first operates by listing and serves to decide how much stock to send to MercadoLibre Full. The second operates by SKU and serves to decide how much to order from the supplier. An honest engine executes them in that order, not the other way around.

The algorithm by listing takes the historical demand of each active listing, runs it through WDD and multiplies by the family's days of minimum and maximum stock. It returns a pair of numbers per listing. Because a listing does not accept fractions, you do not ship 3.8 units to Full, you ship 4, the algorithm rounds up on every line. That rounding is operationally correct: better to have one extra unit in each listing than to lose a sale over decimals. But it introduces a silent drift when you aggregate upward.

The algorithm by SKU takes the sum of the per-listing minimums as a lower bound of the available inventory that must back them, plus the estimated demand from non-Full channels (Colecta sales, physical store, other marketplaces), and calculates the quantity to order from the supplier. If that second half of the calculation ignores the round-ups from the first half, the supplier order ends up systematically short by exactly the amount operations needs to back the shipments to Full. It is an error that in daily operations shows up as the seasonal stockout no one can quite explain and that "gets solved" with a rush order to the supplier every couple of months. The refinement quote said it plainly:

"Those extras that keep piling up because of rounding, at the end of the day you have to buy them. They have to come out of your first restock. From China."

The architectural consequence is that the two algorithms are not independent components: they are a pipeline. The intermediate state of the first, the accumulated round-ups, feeds the second. In the addon they were implemented as two methods of the same engine with guaranteed order; the initial attempt to separate them as independent microservices was abandoned in refinement for reasons not of infrastructure but of correctness.

flowchart TB
  subgraph A1[Algorithm A - by Full listing]
    P1[Demand per listing] --> D1[WDD per listing]
    D1 --> F1[Family min/max days]
    F1 --> Q1[Quantities per listing]
    Q1 --> R1[Round up
per line] R1 --> E1[[Shipment to MercadoLibre Full
per listing]] end subgraph A2[Algorithm B - by SKU to supplier] S1[Sum of minimums per listing] --> C1[Lower bound per SKU] N1[Demand from non-Full channels] --> C2[Aggregated demand per SKU] C1 --> C2 C2 --> R2[Quantity to order from supplier] end R1 -. accumulated round-ups
that must be replenished .-> S1 R2 --> PO[[Draft Purchase Order
via native Odoo]]

7. The key architectural decision: empower Odoo's native scheduler

With both algorithms defined, the underlying question remained: what does the system do with the numbers it produces? The naive answer, and the one almost any team picks by default, is to build a brand-new purchase engine inside the addon: directly create purchase orders, generate internal transfers, manage states. That decision duplicates functionality Odoo already solves, creates two sources of truth for inventory, and turns every future version migration into an archaeology problem.

The decision we made was the opposite and is the heart of the solution: the addon does not buy, does not transfer, does not order; it writes replenishment levels into Odoo's native reorder points (stock.warehouse.orderpoint) and lets the procurement scheduler do its job. The native scheduler runs as an independent cron, reads the orderpoints, reviews virtual stock (what is in the warehouse plus what is inbound minus what is committed), and when it detects that virtual stock drops below the minimum it automatically generates the draft purchase order or the internal transfer, according to the routes and procurement rules configured for that product and that warehouse.

This approach has three tangible gains. The first is correctness: Odoo's logic for purchases and transfers has been polished by the community and Odoo S.A. over years; we are not going to beat it with new code written in weeks. The second is longevity: when Odoo v20 or v21 arrive, the addon will keep working as long as stock.warehouse.orderpoint still exists, and that model is a cornerstone of the ERP, not a marginal artifact. The third is integration with existing operations: the orderpoints appear in Odoo's standard UI, the manager reviews them from the Inventory module they already use, and the generated purchase orders follow the purchasing approval flow the company already knows.

It is worth stating in the negative: the addon does not try to be a parallel MRP engine, does not try to be a MercadoLibre integration (that phase, if the business needs it, is separable), and does not try to be a button to "push stock to MercadoLibre". It is a level calculator that feeds a standard socket. Everything else Odoo does. As the operational person on the client side said during the refinement, after we had gone the long way first:

"I recommend you not to complicate things by generating something to send. We do not need MercadoLibre to have it. What you need is to show the user what they should send. The important thing is the calculation, not the connection."

8. stock.warehouse.orderpoint as the standard protocol

It is worth pausing to look at why Odoo's native reorder point works as such a stable contract. The stock.warehouse.orderpoint model defines, for each product and location combination, four operational fields: minimum quantity, maximum quantity, purchase multiple and lead time. Nothing else. It does not decide when to buy; it does not decide which supplier; it does not generate orders. It only declares a desired state. All the procurement machinery, the _procurement_cron, the routes, the purchase rules, make-to-order manufacturing, revolves around that declared state and acts accordingly.

The practical consequence is that stock.warehouse.orderpoint behaves like a standard socket: any source can write to it and the scheduler will treat it identically. Writing an orderpoint from a custom addon, from the manager's manual UI, from a bulk-import wizard or from a community OCA replenishment module produces exactly the same downstream behavior. The system does not know, and does not care, who set the minimum at fifty; it only knows that from now on it has to replenish whenever virtual stock drops below that mark.

flowchart TB
  A[Custom replenishment addon]
  B[OCA replenishment module]
  C[Manager manual UI]
  D[Bulk import wizard]
  E[(stock.warehouse.orderpoint)]
  F([Odoo native scheduler])
  G[Draft Purchase Order]
  H[Draft internal transfer]
  A --> E
  B --> E
  C --> E
  D --> E
  E --> F
  F --> G
  F --> H

Designing against this socket changes the nature of the problem. Instead of asking "how do I build a purchase engine that respects fiscal rules, supplier terms, multi-warehouse routes and approval channels?", you ask "how do I produce correct numbers to write into product_min_qty and product_max_qty?". The first question opens a multi-year project; the second is tractable in weeks. The rest of the system, correct, audited, integrated, already came with Odoo. Recognizing that asymmetry between what has to be built and what has to be reused is, in practice, the most economical skill of a good implementation team.

9. End-to-end flow

With the conceptual pieces in place, correct cardinality, ABCD classification, Weighted Daily Demand, two linked twin algorithms, orderpoint as the standard socket, the complete system reads like a choreography in five acts, three of them automated and two with human intervention.

Act one, monthly. A monthly cron recalculates the catalog's families. It reads the period's sales report, groups by main product, calculates cumulative value, sorts from largest to smallest and assigns the letter A to the products that represent 70% of value, B to the next 20%, C to the next 10% and D to those that did not sell. The family_id field on product.template is updated.

Act two, daily. A daily cron runs the two twin algorithms. First it calculates per-listing minimums and maximums using WDD and the family's days of stock, rounding up. Then it aggregates those results by SKU and adds the demand from non-Full channels to produce the per-SKU minimums and maximums in the master warehouse. Each calculation line materializes as a replenishment.suggestion record in draft state.

Act three, human. The manager opens the suggestions view. They filter by family, warehouse or SKU. They compare current values against suggested ones (the view shows them side by side) and decide: approve in bulk, approve selectively or discard. The system does not force accepting everything; the manager's judgment is explicit and auditable.

Act four, automated. On approval, the replenishment.suggestion record moves to done and writes the values into the corresponding stock.warehouse.orderpoint. If the orderpoint did not exist, it creates it. Here the addon's work ends.

Act five, native. Odoo's _procurement_cron runs as always, finds the updated orderpoints, calculates virtual stock and generates draft purchase orders or internal transfers according to the routes configured for each product. The purchasing team confirms those RFQs from the module they already use. The warehouse team validates the transfers from the Inventory module. Nothing new to learn.

flowchart LR
  A([Monthly cron]) --> B[Recompute ABCD families on sale.report]
  B --> C[(product.template.family_id)]
  D([Daily cron]) --> E[Algorithm by listing with WDD]
  E --> F[Algorithm by SKU + non-Full channels]
  F --> G[(replenishment.suggestion state=draft)]
  G --> H[Manager reviews and approves]
  H --> I[(stock.warehouse.orderpoint)]
  I --> J([Odoo native _procurement_cron])
  J --> K[Draft Purchase Order]
  J --> L[Draft internal transfer]

10. Auditable history: replenishment.suggestion and the approval flow

That every run of the algorithm generates new records instead of overwriting previous ones is a decision with consequences worth spelling out. The replenishment.suggestion model is transactional: each line captures a moment, which product, which location, which current minimum and maximum, which suggested minimum and maximum, which calculated demand, which family, which user approved and when. Nothing is lost. An awkward change next quarter can be traced back to the exact day it was approved, with the electronic signature of the manager who authorized it.

There are four states and their semantics are rigid. When the daily cron runs, each suggestion is born in draft. The manager moves it to approved when they decide to apply it; the system automatically moves it to done when it finishes writing the corresponding orderpoint. Alternatively, the manager can send it directly to rejected, with the option of leaving a note explaining why. That rejected record is also preserved; a discarded suggestion is often as informative as an accepted one, because it points to where the algorithm loses against human judgment and what signals it is missing.

stateDiagram-v2
  [*] --> draft : daily cron proposes
  draft --> approved : manager approves
  draft --> rejected : manager discards with note
  approved --> done : writes stock.warehouse.orderpoint
  done --> [*]
  rejected --> [*]

This history fulfills two functions that tend to be requested late in the life of an ERP. The first is compliance: in businesses with annual audits, fiscal, quality ISO, demanding suppliers, the trail of who decided what and when is a requirement, not an ornament. The second is continuous improvement: accumulating months of suggestions against approvals lets you compute, with data, in which categories the algorithm hits the mark and in which the manager systematically corrects it. That signal is the raw material for adjusting WDD weights or family stock days with evidence, not with hunches.

11. What changed when migrating to Odoo v19

The addon was originally built on Odoo v18 and, months later, accompanied the client through their migration to Odoo v19. It is a good laboratory to review how much of a design survives when the ERP version underneath changes. The short answer: the part of the addon that respects the standard socket migrates painlessly; the part that couples to internal models always ends up requiring review.

What survived intact was the entire calculation logic. The two twin algorithms, Weighted Daily Demand, the ABCD classification, the replenishment.suggestion model and the crons that orchestrate the pipeline moved to v19 without substantive adjustments. The reason is simple: they read sale.report (a stable model since v14), write stock.warehouse.orderpoint (a central contract of the ERP) and do not depend on internal details of the procurement scheduler. The contact surface with Odoo was, by design, at the edges.

What did require review were two predictable points. One, the XML views for the wizards and the suggestion form: v19 evolved view attributes and some widgets behave differently; we had to adjust declarations, not rewrite them. Two, a low-level method in the extension of product.template that called an internal Odoo helper whose signature changed; we replaced it with the equivalent public API and the rest continued unchanged. Nothing structural.

The collateral gain was Odoo Spreadsheet, which in v19 matures enough to serve as an analysis layer over replenishment.suggestion without needing to export to Excel. The manager can build pivot tables against the suggestion history, cross them with families and visualize them, all inside the ERP.

A context note is worth adding: as of this writing, the engagement with the client has ended. The addon stayed installed in their instance and they took ownership of the code and its operation; Transgenia no longer maintains or supports it. This text documents the design and the method, not an active service. What does remain is the lesson that an addon designed against Odoo's stable contracts, and not against its internals, transfers cleanly between versions even without the people who originally wrote it.

12. What of this is replicable to Amazon FBA, Shopify, Prestashop

Although the case lived on MercadoLibre Full, no essential piece of the design depends on MercadoLibre. The one-to-many relationship between SKU and listing exists on any marketplace that allows listing the same product on several ads; the round-up in consolidated logistics exists on any FBA-type fulfillment. The architecture, then, is transferable with minor adjustments.

Amazon FBA is the most obvious sibling case. The equivalence is almost point-by-point: the ASIN plays the role of the listing (a sales container with its own demand), the SKU is still the physical product, the inventory assigned to FBA is analogous to the Full stock, the relationship between ASINs and SKUs is also one-to-many. The two twin algorithms work identically; only the sources of historical demand change (Amazon Sales Reports instead of MercadoLibre) and the vocabulary. A custom Odoo module designed on this pattern can serve both marketplaces by switching the source connector without touching the logic.

Shopify and Prestashop are slightly different cases because their base model is not consolidated: the store is your own warehouse unless you use a 3PL service. There the by-listing algorithm becomes trivial (one store = one channel, no multiple listings of the same SKU), but the by-SKU algorithm remains valid and gains value if you also operate simultaneously on Shopify plus several marketplaces (Amazon, MercadoLibre, eBay). The pattern stops being "solve the many-to-many problem" and becomes "orchestrate multichannel replenishment against a single master inventory". The addon structure does not change; what changes is how populated the listings table ends up.

What is not transferable without real work is the parameterization of Weighted Daily Demand. Monthly weights are a cultural artifact of the business: year-end bonuses, Buen Fin, automotive repair seasons. Transferring the same weight vector to a sportswear e-commerce or to a B2B distribution business would produce poor estimates. The operational rule is: the formula travels, the weights do not. The first deliverable of any replica should always be to calibrate the weights with at least twelve months of history in the new context.

13. Design errors we corrected along the way

The addon shipped after three explicit rounds of refinement on the code itself, not on requirements. Each round found design errors of the kind that do not produce visible bugs but age badly. Documenting them serves as a toolbox for anyone facing a similar project.

First error: family_id on product.product instead of product.template. The first version assigned the ABCD family to the variant, not to the main product. In a catalog with size or color variants, this meant that the same part could end up classified as A in red and as C in blue, with inconsistent replenishment levels. The correction was to move the field to product.template and, inside the recompute method, escalate from the variant to the parent before writing. The transferable rule: when integrating with sale.report (which groups by product_id), do not confuse variant with main product.

Second error: the cron with the wrong semantic owner. The monthly family recomputation cron originally hung from the product.family model because that was where the method lived. It worked, but it was confusing: the cron modified product.template, not product.family. When someone new opened the code, they spent minutes understanding why the cron was where it was. The correction was to move the cron so it hangs from product.template (the modified model) and have that model internally call the method on product.family. Zero functional change; big readability gain for the next developer.

Third error: order_id as a field name on marketplace.publication. When modeling the MercadoLibre listing, the first proposal named the field that stores the listing identifier order_id. The problem is that order_id in Odoo collides semantically with sale.order.id, which invites confusion and accidental joins during SQL queries. The correction was to rename the field to publication_key: technical, unambiguous, no possibility of clash. Rule: when integrating with an external platform, respect Odoo's vocabulary in your models and prefix foreign identifiers with <channel>_.

Fourth error: location_id optional on marketplace.publication. The field linking the listing to a stock location was modeled as optional in the first version. In practice it always had to be populated because the by-listing algorithm needs to know where it measures available stock. The correction was to mark it required at model level (required=True) and add an upstream validation that rejects listings without a location before they reach the daily cron. Rule: if a field is required by the algorithm, make it required in the model; do not leave the validation as a responsibility of application code.

None of these four errors produced visible failures in initial manual testing. All four would have surfaced as expensive problems six or twelve months later, when the catalog had grown, another developer had joined the code and the business had started leaning on the system for big decisions. Finding them early, in refinement and not in production, is the only reason this text today describes a clean design instead of chronicling a forced migration.

14. Closing: agile refinement as an operational method for Odoo implementations

If there is one takeaway from this case, it is that short, well-framed conversations produce asymmetric value. The meeting that uncovered the many-to-many was shorter than the average status meeting. The refinement round that avoided four structural errors fit in an afternoon. The hypothetical cost of not having those meetings is paid in months and in budget that can no longer be recovered. In an Odoo implementation, the project's most profitable moment is not the moment the code is written: it is the moment when two people look at the same spreadsheet and ask themselves what each column does.

Agile refinement is not a methodological label or a ritual inherited from other industries. It is the most economical mechanism known for reducing the variance of a software project before that variance materializes as code. In Odoo projects it is used little because the implicit promise of the configurable ERP ("you just have to capture the requirement") creates the illusion that the heavy cognitive work is already solved. It is not. The replenishment design this text describes exists the way it does because there was someone asking "why" while everyone else was taking the answers for granted.

If you find yourself designing replenishment for a business with marketplace presence, three decisions save you most of the pain: (1) model SKU and listing as distinct entities with one-to-many cardinality from day zero; (2) write levels into stock.warehouse.orderpoint, not quantities to buy; (3) run the by-listing algorithm first and aggregate to SKU afterward, respecting the accumulated round-ups. With those three rules and an honest refinement method, most of the project turns into calm work.

If you want to see how Transgenia runs this kind of project in practice, enter the gated demo (corporate email, fifteen-day access, zero commitment). If you run a B2B distributor and want to explore how an analogous approach would apply to your inventory, the Sectors › Trading companies pillar summarizes the offer.

I close with a note this text has repeated on purpose and that remains true: the addon stayed with the client. The method stays with Transgenia, and with anyone who read this far.


Frequently asked questions

What makes the same SKU have several listings on MercadoLibre Full?

MercadoLibre does not enforce a one-to-one relationship between physical product and published ad. The same SKU can live in different listings for commercial reasons: one with an anchor price, another with subsidized shipping, another in the leading MELI channel, another as a bundle with a complementary product. Each listing accumulates visits, questions and sales separately, and in Full it requires its own assigned stock.

The operational consequence is that the replenishment calculation cannot treat the SKU as a monolithic unit. Two distinct calculations must be run: one per listing (how much to send to Full for each) and one per SKU (how much to order from the supplier considering all listings and non-Full channels).

Why ABCD and not just classic ABC?

Classic ABC classifies live catalog products into three groups by cumulative sales value (70%, 20%, 10%). The problem is that a real sales report always carries noise: products with zero movement that appear due to data-entry errors or internal transfers that are not real market sales.

Adding the letter D allows you to explicitly flag products with no sales during the evaluated period and take them out of the replenishment calculation. If you do not do this, those products end up in family C with a positive minimum and force unjustified purchases. D is a hygiene label, not a management category.

Is Weighted Daily Demand a proven statistical method?

WDD does not claim to be a novel academic technique. It is a weighted moving average with configurable weights applied to the monthly sales history. Its value is not in mathematical sophistication, but in the fact that it exposes weights as parameters of the system and not as hidden constants inside the code.

That openness turns the manager into an operator of the model: when seasonality changes, when a commercial event is brought forward or when a customs problem is detected, they adjust the weights and the system responds. More elaborate methods (exponential smoothing, ARIMA, neural networks) are valid, but they move the conversation from the business to engineering and usually take longer to pay off their implementation cost.

Why a custom addon instead of just configuring native Odoo?

Odoo has OCA community modules for replenishment with static MIN/MAX rules and some with slightly dynamic rules. We reviewed them before writing our own code. They did not fit for three reasons. First: none models the many-to-many relationship between SKU and marketplace listing; they assume a reorder point is always per product and per warehouse. Second: monthly weights configurable by the manager are a need of the automotive business that does not appear in most OCA modules. Third: the approval chain with replenishment.suggestion as an intermediate step does not exist in the modules we reviewed.

The custom addon does not reinvent the scheduler or the purchase orders. It provides exactly what was missing: ABCD classification, two linked twin algorithms and auditable suggestions before writing orderpoints. Everything else is done by standard Odoo.

Does the same approach work for Amazon FBA or Shopify?

Yes, with adjustments to the connector and the vocabulary, not to the architecture. The pattern (many-to-many between SKU and sales container, two twin algorithms linked by round-ups, writing to stock.warehouse.orderpoint) is the same. Amazon FBA is the most direct case because of its operational similarity with MercadoLibre Full: ASIN plays the role of the listing, inventory in FBA is analogous to Full stock.

On Shopify or Prestashop, unless you use a 3PL, the by-listing algorithm simplifies because the store is your own warehouse. But if you operate simultaneously on Shopify and on a marketplace, the multichannel pattern recovers all of its value. The only thing that does not transfer without work are the monthly WDD weights: seasonal patterns are cultural to the business and must be calibrated with at least twelve months of history in the new context.

← Back to Blog