Ir al contenido

Modelar estados en Odoo: Selection, Many2one o Char

Cuándo cerrar los valores en código, cuándo dejarlos en manos del usuario y cómo traducir los estados de una API externa
19 de octubre de 2026 por
Modelar estados en Odoo: Selection, Many2one o Char
ATEMI, Aitor Atencia

Odoo · Modelado · Campos

Casi todo modelo de negocio tiene un estado: borrador, confirmado, cancelado; abierto, cerrado; en ruta, entregado. La decisión de cómo guardarlo parece trivial y condiciona mucho: si los valores se pueden traducir, si el usuario puede añadir uno nuevo, si el código puede confiar en ellos y qué pasa cuando una API externa envía un valor que nadie había visto antes.

Logo Odoo
La pregunta clave: ¿quién decide qué valores existen, el código o el usuario?

Selection

Cuando el código depende de cada valor

Si tu código hace if trip.state == 'confirmed', el conjunto de valores lo define el código y debe ser un Selection: claves estables en inglés que no cambian nunca, y etiquetas traducibles que sí pueden cambiar. Es lo que usan sale.order, account.move y casi todos los flujos del core.

state = fields.Selection(
    [
        ("draft", "Draft"),
        ("confirmed", "Confirmed"),
        ("done", "Done"),
        ("cancel", "Cancelled"),
    ],
    default="draft", required=True, tracking=True, index=True,
)

Si otro módulo necesita añadir un valor, usa selection_add. En un campo almacenado debes indicar qué hacer con los registros que tengan ese valor cuando el módulo se desinstale:

class FleetTrip(models.Model):
    _inherit = "fleet.trip"

    state = fields.Selection(
        selection_add=[("on_hold", "On Hold"), ("done",)],
        ondelete={"on_hold": "set default"},
    )

Many2one

Cuando los valores los gestiona el usuario

Si cada equipo quiere sus propias columnas en el kanban, o el responsable de calidad necesita añadir un motivo de rechazo sin pedir un desarrollo, los valores son datos, no código: van en un modelo de configuración y el estado es un Many2one. Es el patrón de las etapas de CRM y Proyecto. El precio es que el código ya no puede hacer if stage == 'won': necesita un atributo en la etapa (is_won, fold) que exprese el significado.

class FleetTripStage(models.Model):
    _name = "fleet.trip.stage"
    _description = "Trip Stage"
    _order = "sequence, id"

    name = fields.Char(required=True, translate=True)
    sequence = fields.Integer(default=10)
    fold = fields.Boolean()
    is_closed = fields.Boolean(help="Trips in this stage count as finished.")


# En fleet.trip
stage_id = fields.Many2one("fleet.trip.stage", group_expand="_read_group_stage_ids")

Char + Selection

Cuando el valor viene de una API externa

Los estados que envía un fabricante ("CLOSED", "AJAR", "FAULT_DETECTED") no los controlas tú. Un Selection estricto rechazaría el primer valor nuevo y rompería la sincronización. Lo que funciona es guardar el literal crudo en un Char, fiel a lo que dijo la API, y añadir al lado un Selection calculado mediante un mapeo tolerante, con un valor unknown para lo que no reconoce.

DOOR_STATUS = {"CLOSED": "closed", "OPEN": "open", "AJAR": "open"}


def map_status(mapping, raw):
    if not raw:
        return False
    value = mapping.get(raw.upper())
    if value is None:
        _logger.info("Unknown status %r, mapped to 'unknown'", raw)
        return "unknown"
    return value


class VehicleDoor(models.Model):
    _name = "vehicle.door"
    _description = "Vehicle Door"

    status_raw = fields.Char("Status (raw)", groups="base.group_no_one")
    status = fields.Selection(
        [("open", "Open"), ("closed", "Closed"), ("unknown", "Unknown")],
        compute="_compute_status", store=True,
    )

    @api.depends("status_raw")
    def _compute_status(self):
        for door in self:
            door.status = map_status(DOOR_STATUS, door.status_raw)

Centralizar el mapeo en un único sitio tiene un beneficio que no se ve hasta que falta: en un caso real, la lógica de automatizaciones ya sabía que AJAR significa «no cerrada», pero la ficha de la puerta solo conocía OPEN y CLOSED y mostraba «Desconocido». El conocimiento estaba en el repositorio, solo que en un sitio y no en el otro.

SituaciónTipoEjemplo del core
El código depende de cada valorSelectionsale.order.state
El usuario crea y ordena los valoresMany2one a un modelo de configuracióncrm.lead.stage_id
El valor viene de un sistema externoChar crudo + Selection calculado(patrón de integración)
Un módulo añade valores a otroselection_add + ondeletesms añade Send SMS a ir.actions.server.state

Resumen

Si el código decide los valores, Selection con claves estables. Si los decide el usuario, Many2one a un modelo de configuración con atributos que expresen el significado. Si los decide un tercero, guarda el literal crudo y tradúcelo con un mapeo tolerante y centralizado que nunca rompa la sincronización por un valor desconocido.

en Odoo
API keys en Odoo 19: JSON-2, auth bearer y lo que el scope no protege
La nueva API externa, claves con caducidad y cómo autenticar tus propios endpoints