Food Safety Isn't a Feature Request — It's a Legal Requirement
A dairy cooperative ships 200 pallets of yogurt to three regional distributors. Two weeks later, a routine lab test flags elevated coliform counts in a single batch of raw milk received on March 3rd. The quality manager needs to answer one question in under four hours: which finished products used that milk, where are they now, and which customers need to be notified?
If your ERP can't answer that question — with lot-level granularity, across every production step from receiving to shipment — you fail the FDA's "one-up, one-back" traceability requirement. You fail HACCP. You fail your insurance audit. And you face recalls that cost an average of $10 million per incident in the US food industry (Grocery Manufacturers Association data).
Odoo 19 ships with the building blocks — lot tracking, expiry dates, Bills of Materials, quality control checkpoints — but none of them are configured for food & beverage out of the box. This guide walks through the complete setup: lot/batch tracking with upstream traceability, FEFO (First Expired, First Out) removal strategies, recipe-based BoMs with yield management, allergen tracking, and the quality control checkpoints you need for FDA 21 CFR Part 117 and HACCP compliance.
Configuring Lot and Batch Tracking for Full Upstream/Downstream Traceability
Odoo distinguishes between lots (many units share one tracking number, e.g., a 500-gallon batch of tomato sauce) and serial numbers (one unit, one number). In food & beverage, you almost always want lots. The goal is to trace any finished product back to every raw ingredient lot that went into it — and forward to every customer who received it.
Enable Lot Tracking on Product Categories
Rather than setting tracking on each product individually, configure it at the product category level. This ensures every new raw material or finished good inherits the correct tracking method automatically.
from odoo import models, fields, api
class ProductCategory(models.Model):
_inherit = 'product.category'
enforce_lot_tracking = fields.Boolean(
string='Enforce Lot Tracking (F&B)',
default=False,
help='When enabled, all products in this category '
'will require lot tracking at every inventory operation.',
)
@api.model_create_multi
def create(self, vals_list):
categories = super().create(vals_list)
categories._apply_lot_tracking_to_products()
return categories
def write(self, vals):
res = super().write(vals)
if 'enforce_lot_tracking' in vals:
self._apply_lot_tracking_to_products()
return res
def _apply_lot_tracking_to_products(self):
for category in self.filtered('enforce_lot_tracking'):
products = self.env['product.template'].search([
('categ_id', '=', category.id),
('tracking', '!=', 'lot'),
])
products.write({'tracking': 'lot'})Lot Naming Convention for Food Traceability
FDA and GFSI auditors expect lot numbers to encode meaningful information. A lot number like LOT00042 tells you nothing. A lot number like TOM-2026-03-13-A immediately identifies the product, production date, and shift. Configure Odoo's sequence to generate structured lot names:
<record id="seq_lot_food_production" model="ir.sequence">
<field name="name">Food Production Lot</field>
<field name="code">stock.lot.food</field>
<field name="prefix">%(product_code)s-%(year)s-%(month)s-%(day)s-</field>
<field name="padding">3</field>
<field name="company_id" eval="False"/>
</record>Lot Tracking Coverage by Operation Type
Lot numbers must be captured at every inventory movement, not just receiving and shipping. Here's where lots are recorded across the full food manufacturing flow:
| Operation | Lot Captured | Odoo Operation Type | Traceability Link |
|---|---|---|---|
| Receiving | Supplier lot + internal lot | Receipt | Purchase Order → Lot |
| Raw Material Issue | Lot consumed in production | Manufacturing (component) | Lot → Manufacturing Order |
| Production Output | New finished product lot | Manufacturing (finished) | Manufacturing Order → Lot |
| Internal Transfer | Lot moved between locations | Internal Transfer | Location chain preserved |
| Shipping | Lot sent to customer | Delivery Order | Lot → Customer → Sales Order |
In food manufacturing, you track two lot numbers for every raw material: the supplier's lot (printed on their Certificate of Analysis) and your internal receiving lot. Odoo's stock.lot model has a ref field — use it to store the supplier lot while name holds your internal lot. This dual-lot approach is mandatory for FSMA Section 204 compliance.
Expiry Date Management and FEFO Removal Strategy in Odoo 19
Odoo's product_expiry module adds four date fields to every lot: Best Before Date, Use Date (hard expiry), Removal Date (when to pull from shelves), and Alert Date (internal warning trigger). For food & beverage, you need all four — and you need Odoo to automatically calculate them from the production or receiving date.
| Date Field | Purpose | Example (Fresh Juice) | Who Uses It |
|---|---|---|---|
| Best Before | Quality degrades after this date; still safe | Production + 14 days | Sales, Distribution |
| Use Date | Hard expiry — unsafe after this date | Production + 21 days | Quality, Compliance |
| Removal Date | Pull from warehouse/store shelves | Production + 18 days | Warehouse, Retail |
| Alert Date | Internal alert to prioritize picking | Production + 10 days | Warehouse Manager |
Configure FEFO as the Default Removal Strategy
FIFO (First In, First Out) is Odoo's default. For perishable goods, you need FEFO (First Expired, First Out) — the system picks lots with the nearest expiry date first, regardless of when they were received. A shipment received yesterday with a 3-day shelf life should ship before a shipment received last week with a 30-day shelf life.
Enable FEFO by setting the removal strategy on the warehouse location:
Inventory → Configuration → Warehouses → [Your Warehouse] → Locations → Stock Location → Removal Strategy: First Expiry First Out (FEFO)
Automated Expiry Date Calculation on Receiving
Manually entering four dates per lot on every receiving operation is error-prone and slow. Configure Odoo to auto-calculate all expiry dates from the product template's shelf life settings. On the product form, set the Expiration Time, Best Before Time, Removal Date, and Use Time in days. When a lot is created during receiving, Odoo calculates all four dates from the lot creation date.
For suppliers who provide their own expiry dates (which override your defaults), train receiving staff to manually adjust the Use Date on the lot form. The other three dates should then be recalculated relative to the supplier's date, not the receipt date.
Near-Expiry Alert Dashboard
Configure a scheduled action that runs daily and flags all lots whose Alert Date has passed. These lots appear on a dedicated "Near-Expiry" dashboard visible to warehouse managers and sales teams. Lots past their Removal Date are automatically moved to a quarantine location via an internal transfer — preventing them from being picked for customer orders.
| Product Category | Shelf Life | Alert Before Expiry | Remove Before Expiry | FEFO Impact |
|---|---|---|---|---|
| Fresh Juice | 21 days | 11 days | 3 days | High — daily rotation needed |
| Frozen Meals | 180 days | 30 days | 14 days | Moderate — monthly review |
| Canned Goods | 730 days | 90 days | 30 days | Low — quarterly review |
| Spice Blends | 365 days | 60 days | 14 days | Moderate — monthly review |
Odoo's FEFO strategy sorts by the use_date field (hard expiry), not best_before_date. If your lots have a Best Before date but no Use Date, FEFO falls back to FIFO silently — no warning, no error. Every lot must have a Use Date populated for FEFO to work correctly. We configure this as a required field on receiving operations via a Python constraint.
Recipe Management with Bills of Materials: Yield, Byproducts, and Unit Conversion
In food manufacturing, a "Bill of Materials" is a recipe. But food recipes have complexities that discrete manufacturing BoMs don't: variable yield (flour absorbs different amounts of water depending on humidity), byproducts (whey from cheese production), unit conversions (recipes in kg, purchasing in lbs, inventory in cases), and substitution rules (sunflower oil can replace canola oil at a 1:1 ratio).
Multi-Level BoM with Byproducts
from odoo import models, fields, api
class MrpBomFoodExtension(models.Model):
_inherit = 'mrp.bom'
theoretical_yield = fields.Float(
string='Theoretical Yield (%)',
default=100.0,
help='Expected output as percentage of total input weight. '
'Actual yield is tracked per manufacturing order.',
)
recipe_version = fields.Char(
string='Recipe Version',
tracking=True,
help='R&D version identifier for audit trail.',
)
allergen_ids = fields.Many2many(
'food.allergen',
string='Contains Allergens',
compute='_compute_allergens',
store=True,
)
@api.depends('bom_line_ids.product_id.allergen_ids')
def _compute_allergens(self):
for bom in self:
allergens = self.env['food.allergen']
for line in bom.bom_line_ids:
allergens |= line.product_id.allergen_ids
bom.allergen_ids = allergens
class MrpProductionYield(models.Model):
_inherit = 'mrp.production'
actual_yield_pct = fields.Float(
string='Actual Yield (%)',
compute='_compute_actual_yield',
store=True,
)
yield_variance = fields.Float(
string='Yield Variance (%)',
compute='_compute_actual_yield',
store=True,
)
@api.depends('qty_produced', 'product_qty', 'bom_id.theoretical_yield')
def _compute_actual_yield(self):
for prod in self:
if prod.product_qty > 0:
prod.actual_yield_pct = (
prod.qty_produced / prod.product_qty
) * 100
else:
prod.actual_yield_pct = 0
prod.yield_variance = (
prod.actual_yield_pct
- (prod.bom_id.theoretical_yield or 100.0)
)The yield variance field is critical for food manufacturers. A batch of granola bars that yields 92% when the recipe expects 97% signals a problem — maybe the oat supplier changed moisture content, maybe the mixer wasn't calibrated. Tracking this per manufacturing order lets quality teams catch drift before it becomes a pattern.
Odoo 19's mrp_byproduct feature lets you define secondary outputs on a BoM. For cheese production, your BoM produces cheddar (primary) and whey (byproduct). The whey gets its own lot number and can be sold or used as input in another recipe. Without byproduct tracking, your inventory shows phantom losses — the raw milk "disappeared" because the whey was never recorded.
Allergen Tracking, Nutrition Data, and FDA/HACCP Compliance in Odoo 19
The FDA's Food Allergen Labeling and Consumer Protection Act (FALCPA) requires declaration of the Big Nine allergens: milk, eggs, fish, shellfish, tree nuts, peanuts, wheat, soybeans, and sesame. In the EU, Regulation 1169/2011 extends this to 14 allergens. A mislabeled product isn't just a quality issue — it's a life-threatening liability.
Odoo has no built-in allergen model. You need a custom module that attaches allergen data to raw materials, propagates it through BoMs, and surfaces it on finished product labels and Certificates of Analysis.
from odoo import models, fields
class FoodAllergen(models.Model):
_name = 'food.allergen'
_description = 'Food Allergen'
name = fields.Char(required=True) # e.g., 'Milk', 'Wheat'
code = fields.Char(required=True) # e.g., 'MLK', 'WHT'
regulation = fields.Selection([
('fda', 'FDA (Big Nine)'),
('eu', 'EU Reg. 1169/2011'),
('both', 'Both FDA & EU'),
], default='both', required=True)
icon = fields.Char(help='Allergen icon for label printing')
class ProductTemplateAllergen(models.Model):
_inherit = 'product.template'
allergen_ids = fields.Many2many(
'food.allergen',
string='Contains Allergens',
)
may_contain_ids = fields.Many2many(
'food.allergen',
'product_template_may_contain_rel',
string='May Contain (Cross-Contact)',
help='Allergens present in the production facility '
'but not intentional ingredients.',
)
nutrition_energy_kcal = fields.Float('Energy (kcal per 100g)')
nutrition_fat_g = fields.Float('Fat (g per 100g)')
nutrition_carbs_g = fields.Float('Carbohydrates (g per 100g)')
nutrition_protein_g = fields.Float('Protein (g per 100g)')
nutrition_salt_g = fields.Float('Salt (g per 100g)')HACCP Critical Control Points as Quality Checkpoints
HACCP (Hazard Analysis and Critical Control Points) identifies specific steps in your production process where monitoring prevents food safety hazards. Odoo 19's Quality module lets you attach quality checkpoints to manufacturing operations — but you need to configure them to match your HACCP plan:
| CCP | Operation | Odoo Quality Check Type | Critical Limit | Corrective Action |
|---|---|---|---|---|
| CCP-1 | Receiving | Measure (temperature) | <= 4°C for refrigerated | Reject shipment |
| CCP-2 | Cooking/Pasteurization | Measure (temperature) | >= 72°C for 15 sec | Re-process or discard |
| CCP-3 | Metal Detection | Pass/Fail | No detection above 1.5mm Fe | Quarantine lot, inspect line |
| CCP-4 | Cooling | Measure (temperature) | <= 4°C within 4 hours | Discard batch |
| CCP-5 | Labeling | Pass/Fail (allergen check) | All allergens listed correctly | Hold & relabel |
In Odoo, navigate to Quality → Quality Control → Control Points and create one control point per CCP. Set the operation (e.g., Manufacturing, Receipt), the check type (Measure or Pass/Fail), and the failure message that instructs the operator on the corrective action. Require sign-off with a team leader approval for any failed check before the lot can proceed.
Traceability Reports and Recall Management: From Farm to Fork in Under 4 Hours
The FDA's FSMA Section 204 requires high-risk food companies to provide full traceability records within 24 hours of a request. Best-in-class companies target under 4 hours. Odoo 19's traceability report (Inventory → Reporting → Traceability) shows the full chain for any lot — but only if every step was recorded with lot numbers.
Forward Trace: Raw Material → Finished Products → Customers
A forward trace answers: "This batch of flour (lot FL-2026-03-01-A) tested positive for Salmonella. Which products used it, and who received them?" Odoo's stock.traceability.report walks the move lines from receiving through manufacturing to delivery orders. The output includes:
- Raw material lot → Manufacturing order(s) that consumed it
- Finished product lot(s) produced from that MO
- Delivery order(s) that shipped those finished lots
- Customer name, delivery date, quantity for each shipment
Automating Recall Notifications
When a recall is triggered, speed matters. Configure a server action that, given a lot number, automatically identifies affected customers and drafts notification emails:
from odoo import models, fields, api
from odoo.exceptions import UserError
class FoodRecallWizard(models.TransientModel):
_name = 'food.recall.wizard'
_description = 'Food Recall Notification Wizard'
lot_id = fields.Many2one('stock.lot', required=True,
string='Recalled Lot')
recall_reason = fields.Text(required=True)
recall_class = fields.Selection([
('I', 'Class I — Serious health risk'),
('II', 'Class II — Moderate health risk'),
('III', 'Class III — Low health risk'),
], required=True, default='II')
def action_generate_recall(self):
self.ensure_one()
# Find all outgoing moves for this lot
move_lines = self.env['stock.move.line'].search([
('lot_id', '=', self.lot_id.id),
('picking_id.picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
])
if not move_lines:
raise UserError(
'No outgoing shipments found for lot %s.'
% self.lot_id.name
)
affected_partners = move_lines.mapped(
'picking_id.partner_id'
)
# Create recall record
recall = self.env['food.recall'].create({
'lot_id': self.lot_id.id,
'reason': self.recall_reason,
'recall_class': self.recall_class,
'affected_partner_ids': [
(6, 0, affected_partners.ids)
],
'affected_qty': sum(
move_lines.mapped('quantity')
),
})
# Send notification emails
template = self.env.ref(
'food_safety.email_template_recall'
)
for partner in affected_partners:
template.send_mail(
recall.id,
force_send=True,
email_values={
'email_to': partner.email},
)
return {
'type': 'ir.actions.act_window',
'res_model': 'food.recall',
'res_id': recall.id,
'view_mode': 'form',
}This wizard does in 30 seconds what most food companies do manually in 2-3 days: identify every affected customer, calculate quantities shipped, and send traceable notifications. The recall record itself becomes the audit trail — timestamped, with the responsible user, the affected partners, and the corrective action taken.
Mock Recall Drills: Test Before You Need It
GFSI-benchmarked standards (SQF, BRC, FSSC 22000) require annual mock recall exercises. These drills test whether your team can achieve full traceability — from raw material to customer — within your target time window. In Odoo, run a mock recall by:
- Selecting a random finished product lot from the last 90 days
- Running the forward trace to identify all customers and quantities
- Running the backward trace to identify all raw material lots consumed
- Timing the entire process — document the result for your auditor
- Verifying that the total quantity traced forward matches the production order output (±2% tolerance for yield loss)
If the drill takes more than 4 hours, your traceability configuration has gaps. The most common cause: manufacturing orders where raw material lots were not recorded because operators used "No Tracking" products or consumed from untracked bulk locations.
Nutrition Label Data Management
While Odoo doesn't generate FDA Nutrition Facts panels natively, storing per-100g nutrition data on product templates (as shown in the allergen model above) enables two critical workflows: automated nutrition calculation for finished products based on BoM ingredient quantities, and data export to label design software (NiceLabel, BarTender, or Loftware). The BoM-based calculation sums each ingredient's nutritional contribution proportional to its weight in the recipe — accounting for yield loss.
3 Food & Beverage Mistakes That Cause Audit Failures in Odoo
FEFO Is Enabled but Use Date Is Empty on Incoming Lots
You set the removal strategy to FEFO on your cold storage location. Warehouse staff scan lots and ship orders. Everything looks correct — until an auditor finds that 30% of lots have no Use Date. Odoo's FEFO falls back to FIFO for lots without a use_date, silently. No warning in the UI, no error in the logs. You think you're shipping the nearest-expiry products first, but you're actually shipping in receipt order for every lot where receiving staff skipped the expiry date field.
Add a Python constraint on stock.move.line that blocks validation of any incoming transfer where the product has use_expiry_date = True but the lot's use_date is empty. No exceptions, no overrides. If the supplier didn't provide an expiry date, the receiving clerk contacts them before the goods enter inventory.
Allergen Data Doesn't Propagate Through Multi-Level BoMs
Your chocolate chip cookie recipe lists "chocolate chips" as an ingredient. The chocolate chips are themselves manufactured from a sub-BoM that includes milk powder and soy lecithin. If your allergen computation only checks the top-level BoM lines, the finished cookie shows no milk or soy allergen — because those allergens live on the sub-BoM's components, not the chocolate chip product itself. A customer with a milk allergy eats the cookie. This is a labeling failure with legal liability.
The allergen computation must be recursive. Walk every BoM line; if a component is itself a manufactured product with a BoM, descend into that BoM and collect allergens from all leaf-node raw materials. Cache the result on the finished product and trigger recomputation whenever any ingredient's allergen data changes. This is the only reliable approach for multi-level food manufacturing.
Quality Checks Exist but Are Not Blocking
You configure HACCP quality checkpoints on receiving and manufacturing operations. The checks appear in the UI. But when a warehouse worker receives a shipment and skips the temperature check, Odoo still validates the receipt. The quality check shows as "To Do" forever, but the inventory is already in stock and available for production. Your HACCP plan says temperature is a Critical Control Point, but your ERP treats it as optional.
Set the quality control point's Control Type to "Before Operation" and enable Lock on Fail. This prevents the transfer from being validated until all quality checks pass. For CCPs, also configure the Team Leader Approval option so that a failed check requires a supervisor override — creating the documented deviation record that auditors expect.
What Proper Food Traceability Saves Your Business
Food safety compliance isn't optional — but the ROI extends far beyond avoiding fines:
Full lot traceability reduces recall identification time from 2-3 days (manual spreadsheets) to under 4 hours. Smaller recall scope means fewer products pulled and less revenue lost.
FEFO removal strategy with automated expiry alerts ensures nearest-expiry products ship first. Clients report 30-40% reduction in expired inventory write-offs within 6 months.
With lot-level traceability, allergen records, and HACCP quality logs in Odoo, FDA and GFSI audit preparation drops from weeks of spreadsheet gathering to a single day of report generation.
Beyond the numbers: major retailers (Walmart, Costco, Whole Foods) now require suppliers to demonstrate end-to-end traceability as a condition of doing business. The companies that can produce a full forward/backward trace report in hours — not days — win shelf space. The ones that can't get replaced.
Optimization Metadata
Configure Odoo 19 for food & beverage manufacturing. Covers lot tracking, FEFO expiry management, recipe BoMs, allergen tracking, HACCP quality checks, and recall automation.
1. "Configuring Lot and Batch Tracking for Full Upstream/Downstream Traceability"
2. "Expiry Date Management and FEFO Removal Strategy in Odoo 19"
3. "Recipe Management with Bills of Materials: Yield, Byproducts, and Unit Conversion"
4. "Allergen Tracking, Nutrition Data, and FDA/HACCP Compliance in Odoo 19"