Your Warehouse Ships 500 Orders a Day. How Many Get Inspected?
Most Odoo implementations treat quality control as an afterthought. Inventory is configured, manufacturing routes are set up, and somewhere in month three someone asks: "Wait, how do we reject a batch that fails inspection?" By then, the warehouse team has already built a parallel process in spreadsheets — tracking defects in Google Sheets, quarantining stock by physically moving pallets to a corner, and emailing the purchasing team when a vendor ships bad product.
Odoo 19's Quality module (quality_control, quality_mrp, quality_stock) eliminates this shadow process. It lets you define quality control points that automatically trigger inspections at specific stages — incoming receipt, manufacturing work order, outbound picking. Each inspection can be a simple pass/fail, a dimensional measurement with tolerances, or a photo capture for visual verification. When an inspection fails, the system can automatically quarantine stock, raise alerts, and notify responsible teams — no spreadsheets, no manual moves, no forgotten follow-ups.
This guide walks through a complete quality control setup in Odoo 19: from enabling the module and defining control points, through configuring each inspection type, to building automated quarantine workflows that move failed stock to a dedicated location without human intervention.
Setting Up Quality Control Points in Odoo 19: Where, When, and What to Inspect
A quality control point (QCP) is a rule that tells Odoo: "Every time operation X happens for product Y, create an inspection." QCPs are the backbone of the entire quality system. Get them right and inspections happen automatically. Get them wrong and your team either inspects nothing or drowns in unnecessary checks.
Step 1: Enable the Quality Module
Navigate to Settings → General Settings → Inventory and enable Quality. This installs quality_control and its dependencies. If you use Manufacturing, also install quality_mrp for work order integration.
Step 2: Define a Quality Control Point
Go to Quality → Quality Control → Control Points and create a new record. Here is a Python example showing how to programmatically create QCPs for incoming inspections:
from odoo import models, fields, api
class QualityControlPointSetup(models.TransientModel):
"""Wizard to bulk-create quality control points for
all products in a given category."""
_name = 'quality.control.point.setup'
_description = 'Bulk QCP Setup Wizard'
product_category_id = fields.Many2one(
'product.category', string='Product Category', required=True,
)
picking_type_id = fields.Many2one(
'stock.picking.type', string='Operation Type', required=True,
help='e.g., Receipts, Delivery Orders, Internal Transfers',
)
test_type_id = fields.Many2one(
'quality.point.test_type', string='Inspection Type', required=True,
)
team_id = fields.Many2one(
'quality.alert.team', string='Quality Team',
)
note = fields.Html(string='Instructions')
def action_create_control_points(self):
"""Create one QCP per product in the selected category."""
QCP = self.env['quality.point']
products = self.env['product.product'].search([
('categ_id', 'child_of', self.product_category_id.id),
('type', '=', 'product'), # Only storable products
])
vals_list = []
for product in products:
vals_list.append({
'name': f'Incoming QC - {product.name}',
'product_ids': [(4, product.id)],
'picking_type_ids': [(4, self.picking_type_id.id)],
'test_type_id': self.test_type_id.id,
'team_id': self.team_id.id if self.team_id else False,
'note': self.note or '',
'company_id': self.env.company.id,
})
created = QCP.create(vals_list)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Quality Control Points Created',
'message': f'{len(created)} control points created.',
'type': 'success',
},
}Step 3: Configure the QCP Form
Each QCP has these critical fields. Getting the combination right determines whether your inspections are useful or noise:
| Field | What It Controls | Recommended Setting |
|---|---|---|
| Product | Which product(s) trigger this inspection | Set specific products for high-risk items; leave blank for category-wide rules |
| Operation Type | Which warehouse operation triggers the check | Receipts for incoming QC, Manufacturing for in-process QC |
| Control per | One check per operation, per product, or per lot | Per lot for traceability; per operation for general screening |
| Type | Pass/Fail, Measure, Take a Picture, or custom | Pass/Fail for visual checks; Measure for dimensional tolerances |
| Quality Team | Who gets notified and who is responsible | Assign a dedicated QC team, not the generic warehouse team |
| Instructions | What the inspector sees on the check form | Include photos of acceptable vs. defective. Be specific. |
"Control per operation" creates one check per transfer — a single receipt with 10 products generates one check. "Control per product" creates one check per product line — that same receipt generates 10 checks. "Control per lot" creates one check per lot/serial number. For regulated industries (food, pharma, aerospace), always use per-lot. For general merchandise, per-product is the right balance between coverage and overhead.
Odoo 19 Inspection Types: Pass/Fail, Measure, and Picture-Based Quality Checks
Odoo 19 ships with three built-in inspection types. Each serves a different purpose, and choosing the wrong one creates friction for inspectors:
Pass/Fail Inspections
The simplest type. The inspector sees the product, reads the instructions, and clicks Pass or Fail. Use this for visual inspections: "Does the packaging look damaged?", "Is the label printed correctly?", "Are all items present in the kit?"
Measure Inspections
The inspector enters a numeric value. The QCP defines a norm (target) and tolerance (acceptable range). Odoo automatically marks the check as passed or failed based on whether the measurement falls within tolerance. This is essential for dimensional quality: "Measure the shaft diameter — norm 25.0mm, tolerance +/- 0.1mm."
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class QualityCheckMeasureExtension(models.Model):
"""Extend quality checks to support multi-point measurements
and statistical process control (SPC) tracking."""
_inherit = 'quality.check'
measure_values = fields.Text(
string='Individual Measurements',
help='Comma-separated measurements for SPC analysis',
)
cpk_index = fields.Float(
string='Cpk Index', digits=(4, 3), compute='_compute_cpk',
store=True,
)
@api.depends('measure_values', 'point_id.norm',
'point_id.tolerance_min', 'point_id.tolerance_max')
def _compute_cpk(self):
"""Calculate process capability index from measurements."""
import statistics
for check in self:
if not check.measure_values or not check.point_id:
check.cpk_index = 0.0
continue
try:
values = [
float(v.strip())
for v in check.measure_values.split(',')
if v.strip()
]
except ValueError:
check.cpk_index = 0.0
continue
if len(values) < 2:
check.cpk_index = 0.0
continue
mean = statistics.mean(values)
stdev = statistics.stdev(values)
if stdev == 0:
check.cpk_index = 99.99 # Perfect process
continue
usl = check.point_id.tolerance_max
lsl = check.point_id.tolerance_min
cpu = (usl - mean) / (3 * stdev)
cpl = (mean - lsl) / (3 * stdev)
check.cpk_index = min(cpu, cpl)
def action_validate_measurement(self):
"""Validate measurement against tolerances and
auto-fail if out of spec."""
self.ensure_one()
if not self.measure:
raise ValidationError('Enter a measurement before validating.')
norm = self.point_id.norm
tol_min = self.point_id.tolerance_min
tol_max = self.point_id.tolerance_max
if tol_min <= self.measure <= tol_max:
self.do_pass()
else:
self.do_fail()Picture Inspections
The inspector takes a photo directly from the check form (works on mobile and tablet). The image is stored on the check record for audit purposes. Use this for surface defects, label verification, or any inspection where visual evidence is required for compliance.
Registering Custom Inspection Types
Odoo 19 lets you register additional test types beyond the three defaults. Here is how to add a "Barcode Scan" inspection type that verifies the scanned barcode matches the expected product:
from odoo import models, fields
class QualityPointTestType(models.Model):
_inherit = 'quality.point.test_type'
# Odoo 19 uses a selection-style technical_name
# to drive the check form rendering
technical_name = fields.Char()
# In your module's data file, register the new type:
# (see XML below)<odoo>
<data noupdate="1">
<record id="test_type_barcode" model="quality.point.test_type">
<field name="name">Barcode Scan</field>
<field name="technical_name">barcode_scan</field>
</record>
</data>
</odoo>Warehouse inspectors rarely sit at a desk. Odoo 19's quality check forms are fully responsive and work on mobile browsers. The picture inspection type uses the device camera directly. For measure inspections, consider connecting Bluetooth calipers or scales via the IoT Box — the measurement auto-populates the field without manual entry, eliminating transcription errors.
Quality Alerts in Odoo 19: Automated Escalation When Inspections Fail
When an inspection fails, Odoo creates a quality alert. Alerts are Odoo's mechanism for tracking defects from discovery through root cause analysis to corrective action. Think of them as "defect tickets" — they have a responsible team, a pipeline stage (New → Confirmed → In Progress → Done), and full chatter history.
Automatic Alert Creation on Failure
By default, when an inspector clicks Fail on a quality check, Odoo creates an alert linked to the check, the product, the lot (if applicable), and the source operation. But the default behavior stops there — no email, no assignment, no escalation. Here is how to add automated notification logic:
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class QualityAlertAutomation(models.Model):
_inherit = 'quality.alert'
severity = fields.Selection([
('low', 'Low — Cosmetic'),
('medium', 'Medium — Functional'),
('high', 'High — Safety'),
('critical', 'Critical — Recall Risk'),
], default='medium', required=True, tracking=True)
vendor_id = fields.Many2one(
'res.partner', string='Vendor',
compute='_compute_vendor', store=True,
)
total_affected_qty = fields.Float(
string='Affected Quantity',
help='Total units in the defective lot/batch',
)
@api.depends('lot_id')
def _compute_vendor(self):
"""Auto-fill vendor from the lot's original receipt."""
for alert in self:
if alert.lot_id:
# Find the original receipt move for this lot
move = self.env['stock.move.line'].search([
('lot_id', '=', alert.lot_id.id),
('picking_id.picking_type_code', '=', 'incoming'),
], limit=1, order='date asc')
alert.vendor_id = (
move.picking_id.partner_id if move else False
)
else:
alert.vendor_id = False
@api.model_create_multi
def create(self, vals_list):
"""Override create to send notifications based on severity."""
alerts = super().create(vals_list)
for alert in alerts:
self._send_severity_notification(alert)
return alerts
def _send_severity_notification(self, alert):
"""Route notifications based on alert severity."""
if alert.severity in ('high', 'critical'):
# Notify quality manager immediately
template = self.env.ref(
'quality_custom.mail_template_critical_alert',
raise_if_not_found=False,
)
if template and alert.team_id.alias_user_id:
template.send_mail(
alert.id, force_send=True,
email_values={
'email_to': alert.team_id.alias_user_id.email,
},
)
_logger.warning(
'CRITICAL quality alert #%s for product %s, lot %s',
alert.name, alert.product_id.display_name,
alert.lot_id.name if alert.lot_id else 'N/A',
)
if alert.severity == 'critical' and alert.vendor_id:
# Auto-create vendor complaint
alert.message_post(
body=f'Critical defect detected. '
f'Vendor: {alert.vendor_id.name}. '
f'Initiating vendor complaint workflow.',
message_type='notification',
)Alert Pipeline Configuration
Configure your quality alert stages to match your corrective action process. A proven pipeline for manufacturing environments:
| Stage | Who Owns It | Expected Action | SLA |
|---|---|---|---|
| New | Quality Inspector | Document defect, attach photos, assign severity | Immediate |
| Confirmed | Quality Team Lead | Verify defect, confirm severity, initiate quarantine | 4 hours |
| Root Cause | Quality Engineer | 5-Why analysis, identify corrective action | 48 hours |
| Corrective Action | Responsible Department | Implement fix, update process, retrain if needed | 1 week |
| Done | Quality Manager | Verify fix effectiveness, close alert | Review in 30 days |
Every quality alert with a vendor should feed into your vendor evaluation. Odoo 19's Purchase module has a built-in vendor rating system. Use the vendor_id computed field above to automatically track defect rates per supplier. When a vendor's defect rate exceeds your threshold (say 5%), trigger a review workflow. This turns reactive quality management into proactive supply chain improvement.
Automated Quarantine Workflows: Moving Failed Stock Without Human Intervention
Quarantine is where most Odoo quality setups break down. The inspection fails, an alert is created, but the stock stays in the main warehouse location. An operator can still pick it for a customer order. Someone has to manually create an internal transfer to move the defective product to a quarantine location. This manual step gets forgotten 30% of the time.
The solution: automate the quarantine move. When a quality check fails, Odoo should immediately create and validate an internal transfer moving the affected quantity to a dedicated quarantine location.
Step 1: Create the Quarantine Location
Go to Inventory → Configuration → Warehouses, select your warehouse, and under Locations, create a child location called WH/Quarantine. Mark it as an internal location. Optionally, set a removal strategy of FEFO so quarantined stock with expiration dates gets reviewed first.
<odoo>
<data noupdate="1">
<!-- Quarantine location under main warehouse -->
<record id="stock_location_quarantine" model="stock.location">
<field name="name">Quarantine</field>
<field name="usage">internal</field>
<field name="location_id" ref="stock.warehouse0" />
<field name="scrap_location">False</field>
<field name="company_id" ref="base.main_company" />
</record>
<!-- Internal transfer type for quarantine moves -->
<record id="picking_type_quarantine" model="stock.picking.type">
<field name="name">Quarantine Transfers</field>
<field name="code">internal</field>
<field name="sequence_code">QRN</field>
<field name="default_location_src_id" ref="stock.stock_location_stock" />
<field name="default_location_dest_id" ref="stock_location_quarantine" />
<field name="warehouse_id" ref="stock.warehouse0" />
<field name="company_id" ref="base.main_company" />
</record>
</data>
</odoo>Step 2: Auto-Quarantine on Inspection Failure
This is the core automation. Override the do_fail method on quality.check to automatically create and validate an internal transfer:
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class QualityCheckQuarantine(models.Model):
_inherit = 'quality.check'
quarantine_picking_id = fields.Many2one(
'stock.picking', string='Quarantine Transfer',
readonly=True, copy=False,
)
def do_fail(self):
"""Override to auto-quarantine stock on failure."""
res = super().do_fail()
for check in self:
if check.picking_id and check.product_id:
check._create_quarantine_transfer()
return res
def _create_quarantine_transfer(self):
"""Create an internal transfer to move failed stock
from its current location to quarantine."""
self.ensure_one()
quarantine_loc = self.env.ref(
'quality_custom.stock_location_quarantine',
raise_if_not_found=False,
)
if not quarantine_loc:
_logger.error(
'Quarantine location not found. '
'Cannot auto-quarantine for check %s.', self.name,
)
return
picking_type = self.env.ref(
'quality_custom.picking_type_quarantine',
raise_if_not_found=False,
)
if not picking_type:
return
# Determine source location and quantity
source_loc = self.picking_id.location_dest_id
failed_qty = self._get_failed_quantity()
if failed_qty <= 0:
return
picking_vals = {
'picking_type_id': picking_type.id,
'location_id': source_loc.id,
'location_dest_id': quarantine_loc.id,
'origin': _('QC Fail: %s', self.name),
'move_ids': [(0, 0, {
'name': _('Quarantine: %s', self.product_id.display_name),
'product_id': self.product_id.id,
'product_uom_qty': failed_qty,
'product_uom': self.product_id.uom_id.id,
'location_id': source_loc.id,
'location_dest_id': quarantine_loc.id,
'lot_ids': (
[(4, self.lot_id.id)] if self.lot_id else []
),
})],
}
picking = self.env['stock.picking'].create(picking_vals)
picking.action_confirm()
picking.action_assign()
# Auto-validate if stock is available
for move_line in picking.move_line_ids:
move_line.quantity = move_line.quantity_product_uom
picking.button_validate()
self.quarantine_picking_id = picking.id
self.message_post(
body=_(
'Auto-quarantine: %(qty)s %(uom)s of %(product)s '
'moved to %(loc)s (Transfer: %(ref)s)',
qty=failed_qty,
uom=self.product_id.uom_id.name,
product=self.product_id.display_name,
loc=quarantine_loc.complete_name,
ref=picking.name,
),
message_type='notification',
)
_logger.info(
'Auto-quarantine transfer %s created for check %s',
picking.name, self.name,
)
def _get_failed_quantity(self):
"""Determine the quantity to quarantine.
For lot-controlled products, quarantine the full lot qty.
For non-lot products, quarantine the received qty."""
self.ensure_one()
if self.lot_id:
# Quarantine the entire lot
quant = self.env['stock.quant'].search([
('lot_id', '=', self.lot_id.id),
('location_id', '=',
self.picking_id.location_dest_id.id),
('quantity', '>', 0),
], limit=1)
return quant.quantity if quant else 0.0
# No lot: quarantine the qty from the source move
move_line = self.picking_id.move_line_ids.filtered(
lambda ml: ml.product_id == self.product_id
)
return sum(move_line.mapped('quantity'))Step 3: Add a View for the Quarantine Link
Extend the quality check form so inspectors can see and navigate to the quarantine transfer:
<odoo>
<record id="quality_check_view_form_inherit_quarantine"
model="ir.ui.view">
<field name="name">quality.check.form.quarantine</field>
<field name="model">quality.check</field>
<field name="inherit_id"
ref="quality_control.quality_check_view_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="after">
<field name="quarantine_picking_id"
readonly="1"
invisible="not quarantine_picking_id"
widget="many2one"
options="{{'no_create': True}}" />
</xpath>
</field>
</record>
</odoo>A common mistake is using Odoo's built-in Virtual/Scrap location for quarantine. Scrap locations remove stock from inventory valuation. Quarantined stock is not scrapped — it's under review. It might be reworked, returned to vendor, or eventually scrapped after investigation. Use a regular internal location so the stock remains in your valuation reports and on-hand quantity calculations until a disposition decision is made.
Integrating Quality Control with Manufacturing and Inventory in Odoo 19
Quality control becomes powerful when it is woven into your manufacturing and inventory operations — not bolted on as a separate process. Odoo 19's quality_mrp module connects quality checks to work orders, enabling in-process inspections that catch defects mid-production rather than at the end.
Work Order Quality Checks
When you create a QCP with the operation type set to a manufacturing operation, Odoo adds quality checks as steps within the work order. The operator cannot proceed to the next manufacturing step until the quality check is completed. This is how you enforce inspection gates in production.
from odoo import models, fields, api
class MrpProductionQuality(models.Model):
"""Add quality summary fields to manufacturing orders."""
_inherit = 'mrp.production'
quality_check_count = fields.Integer(
compute='_compute_quality_counts',
)
quality_alert_count = fields.Integer(
compute='_compute_quality_counts',
)
quality_pass_rate = fields.Float(
string='Pass Rate (%)',
compute='_compute_quality_counts',
digits=(5, 1),
)
@api.depends('check_ids', 'check_ids.quality_state')
def _compute_quality_counts(self):
for production in self:
checks = production.check_ids
production.quality_check_count = len(checks)
production.quality_alert_count = len(
checks.filtered(
lambda c: c.quality_state == 'fail'
).mapped('alert_ids')
)
passed = len(
checks.filtered(
lambda c: c.quality_state == 'pass'
)
)
total = len(
checks.filtered(
lambda c: c.quality_state in ('pass', 'fail')
)
)
production.quality_pass_rate = (
(passed / total * 100) if total else 0.0
)Inventory Receipt Inspections
For incoming goods, the standard flow is: Receive → Inspect → Put Away. In Odoo 19, you achieve this with a two-step (or three-step) receipt route where the quality check is triggered on the first step. The product lands in an Input location, gets inspected, and only moves to stock after passing QC. Failed items are quarantined automatically using the logic from Section 04.
from odoo import models, api
class StockPickingQC(models.Model):
"""Block transfer validation if quality checks are pending."""
_inherit = 'stock.picking'
def button_validate(self):
"""Prevent validation if any quality checks are unresolved."""
for picking in self:
pending_checks = self.env['quality.check'].search([
('picking_id', '=', picking.id),
('quality_state', '=', 'none'),
])
if pending_checks:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Pending Quality Checks',
'message': (
f'{len(pending_checks)} quality check(s) '
f'must be completed before validation.'
),
'type': 'warning',
'sticky': True,
},
}
return super().button_validate()Reporting: Quality Dashboard
Use Odoo 19's built-in reporting or create a custom dashboard to track these KPIs:
| KPI | Formula | Target | Where to Track |
|---|---|---|---|
| First Pass Yield | Passed checks / Total checks x 100 | > 95% | Quality → Reports |
| Defect Rate by Vendor | Failed receipts / Total receipts per vendor | < 2% | Custom dashboard |
| Alert Resolution Time | Avg days from alert creation to Done stage | < 5 days | Quality Alerts pipeline |
| Quarantine Aging | Days stock sits in quarantine location | < 14 days | Inventory → Reporting |
| Cpk Index (SPC) | Process capability from measure inspections | > 1.33 | Custom field on quality check |
Not every unit needs inspection. For high-volume, low-risk items, use AQL sampling plans (Acceptable Quality Level). Odoo does not ship with built-in AQL sampling, but you can implement it by adding a sample_percentage field to your QCPs and overriding the check creation logic to only generate checks for a random subset of received quantities. The rule of thumb: 100% inspection for safety-critical items and new vendors, sampling for established supply chains with proven track records.
4 Quality Control Mistakes That Let Defective Product Reach Customers
Quarantine Location Is Not Excluded from Available Stock
You create a quarantine location, move defective stock there, and feel good. Then a sales order reserves that stock because Odoo counts all internal locations as available by default. The customer receives the exact product that failed inspection. This happens silently — no error, no warning.
Configure push/pull rules so that the quarantine location is never a source for outbound operations. Alternatively, use a dedicated route that excludes the quarantine location from procurement. The simplest approach: make the quarantine location a child of a non-sellable parent location, or set its scrap_location field to True temporarily (though this affects valuation — use with caution).
Quality Checks Created But Never Completed
QCPs generate checks automatically, but nothing forces the warehouse team to complete them. The receipt is validated, stock enters the main location, and the quality check sits in "To Do" status forever. Your quality control exists on paper but not in practice.
Use the button_validate override from Section 05 to block transfer validation until all quality checks are completed. This is a hard gate — the operator cannot move stock past the inspection stage without recording a result. For manufacturing, the work order step approach enforces this naturally.
No Disposition Workflow for Quarantined Stock
Stock gets quarantined but nobody decides what happens next. After 6 months, your quarantine location has 200 SKUs and $50,000 in frozen inventory. The quality team says "we're investigating." The finance team says "where did our inventory go?" The warehouse team says "we're out of space."
Create a disposition workflow with three outcomes: Rework (move to manufacturing), Return to Vendor (create RMA), or Scrap (move to scrap location). Attach a scheduled action that flags quarantine alerts older than 14 days. No stock should sit in quarantine without a disposition decision for more than 2 weeks.
Measurement Tolerances Set Incorrectly in the QCP
A measure-type QCP has norm, tolerance_min, and tolerance_max fields. We have seen setups where the tolerance is entered as the absolute value instead of the boundary value. Example: a shaft should be 25.0mm +/- 0.1mm. The correct config is norm=25.0, tolerance_min=24.9, tolerance_max=25.1. But teams enter tolerance_min=0.1, tolerance_max=0.1 — and every measurement over 0.1mm fails.
Add a validation constraint on the QCP that checks tolerance_min < norm < tolerance_max. Also add an onchange helper that auto-fills boundaries when the user enters the norm and a +/- delta value. This prevents the most common data entry error in measure inspections.
What Automated Quality Control Saves Your Business
Quality failures are expensive. A defect caught at incoming inspection costs cents. The same defect caught by a customer costs dollars — in returns, replacements, support time, and lost trust. Here is the math:
Catching defects at receipt instead of after shipping eliminates the most expensive failure mode: returns with shipping, restocking, and customer apology costs.
Replace manual defect tracking in Google Sheets with automated alerts, quarantine transfers, and disposition workflows — all inside Odoo.
Every inspection, failure, quarantine move, and corrective action is logged with timestamp, user, and linked documents. ISO 9001 and FDA auditors get what they need instantly.
The hidden ROI: vendor accountability. When every incoming defect is tracked by vendor, lot, and severity, you have data-driven leverage in vendor negotiations. "Your defect rate was 8% last quarter — here are the 47 quality alerts with photos" is a very different conversation than "we think there were some quality issues."
Optimization Metadata
Complete guide to quality control in Odoo 19. Configure quality control points, pass/fail and measure inspections, quality alerts, and automated quarantine workflows.
1. "Setting Up Quality Control Points in Odoo 19: Where, When, and What to Inspect"
2. "Odoo 19 Inspection Types: Pass/Fail, Measure, and Picture-Based Quality Checks"
3. "Automated Quarantine Workflows: Moving Failed Stock Without Human Intervention"
4. "4 Quality Control Mistakes That Let Defective Product Reach Customers"