58 KiB
58 KiB
SPEC-TRAZABILIDAD-LOTES-SERIES: Trazabilidad Completa de Lotes y Números de Serie
Metadata
- Código: SPEC-MGN-005
- Módulo: Inventario / Trazabilidad
- Gap Relacionado: GAP-MGN-005-002
- Prioridad: P1
- Esfuerzo Estimado: 13 SP
- Versión: 1.0
- Última Actualización: 2025-01-09
- Referencias: Odoo stock.lot, product_expiry, mrp
1. Resumen Ejecutivo
1.1 Objetivo
Implementar un sistema completo de trazabilidad de lotes y números de serie que permita:
- Seguimiento de lotes (batch tracking) para productos perecederos
- Seguimiento de números de serie individuales para equipos/electrónica
- Trazabilidad ascendente (origen) y descendente (destino)
- Gestión de fechas de caducidad con estrategia FEFO
- Recall de productos por lote/serie
- Integración con manufactura (componentes → producto terminado)
1.2 Alcance
- Modelo de datos para lotes y series
- Tipos de seguimiento (none, lot, serial)
- Validaciones de unicidad y consistencia
- Algoritmos de trazabilidad bidireccional
- Estrategias de salida (FIFO, LIFO, FEFO)
- Gestión de caducidades y alertas
- Integración con código de barras GS1
- API REST para consultas de trazabilidad
1.3 Tipos de Seguimiento
| Tipo | Descripción | Cantidad por Movimiento | Caso de Uso |
|---|---|---|---|
none |
Sin seguimiento | Cualquiera | Commodities, materiales a granel |
lot |
Por lote/batch | Múltiples unidades | Alimentos, farmacéuticos, químicos |
serial |
Por número de serie | Exactamente 1.0 | Electrónica, equipos, vehículos |
2. Modelo de Datos
2.1 Diagrama Entidad-Relación
┌─────────────────────────────────┐
│ products.products │
│─────────────────────────────────│
│ id (PK) │
│ tracking │──┐
│ use_expiration_date │ │
│ expiration_time │ │
│ use_time │ │
│ removal_time │ │
│ alert_time │ │
│ lot_properties_definition │ │
└────────────────┬────────────────┘ │
│ │
│ 1:N │
▼ │
┌─────────────────────────────────┐ │
│ inventory.lots │ │
│─────────────────────────────────│ │
│ id (PK) │ │
│ name (UNIQUE per product) │ │
│ ref │ │
│ product_id (FK) ◄─────────────┼──┘
│ company_id (FK) │
│ expiration_date │
│ use_date │
│ removal_date │
│ alert_date │
│ lot_properties (JSONB) │
│ product_qty (computed) │
└────────────────┬────────────────┘
│
┌────────┴────────┐
│ │
▼ ▼
┌───────────────┐ ┌─────────────────────────┐
│ inventory │ │ inventory.move_lines │
│ .quants │ │─────────────────────────│
│───────────────│ │ id (PK) │
│ id (PK) │ │ move_id (FK) │
│ product_id │ │ lot_id (FK) │
│ lot_id (FK) │ │ lot_name │
│ location_id │ │ quantity │
│ quantity │ │ tracking │
│ reserved_qty │ │ consume_line_ids (M2M)│
│ in_date │ │ produce_line_ids (M2M)│
│ removal_date │ └─────────────────────────┘
└───────────────┘ │
│
▼
┌─────────────────────────────┐
│ move_line_consume_rel │
│─────────────────────────────│
│ consume_line_id (FK) │
│ produce_line_id (FK) │
│ (Tabla de relación M2M) │
└─────────────────────────────┘
2.2 Definición de Tablas
2.2.1 Extensión de products.products
-- Agregar campos de tracking a productos
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
tracking VARCHAR(16) NOT NULL DEFAULT 'none'
CHECK (tracking IN ('none', 'lot', 'serial'));
-- Configuración de caducidad
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
use_expiration_date BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
expiration_time INTEGER; -- Días hasta caducidad desde recepción
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
use_time INTEGER; -- Días antes de caducidad para "consumir preferentemente"
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
removal_time INTEGER; -- Días antes de caducidad para remover de venta
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
alert_time INTEGER; -- Días antes de caducidad para alertar
-- Propiedades dinámicas por lote
ALTER TABLE products.products ADD COLUMN IF NOT EXISTS
lot_properties_definition JSONB DEFAULT '[]';
-- Constraint de consistencia
ALTER TABLE products.products ADD CONSTRAINT chk_expiration_config CHECK (
use_expiration_date = false OR (
expiration_time IS NOT NULL AND
expiration_time > 0
)
);
-- Índice para productos con tracking
CREATE INDEX idx_products_tracking ON products.products(tracking)
WHERE tracking != 'none';
2.2.2 inventory.lots
CREATE TABLE inventory.lots (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
name VARCHAR(128) NOT NULL,
ref VARCHAR(256), -- Referencia interna/externa
-- Relaciones
product_id UUID NOT NULL REFERENCES products.products(id),
company_id UUID NOT NULL REFERENCES core.companies(id),
-- Fechas de caducidad
expiration_date TIMESTAMPTZ,
use_date TIMESTAMPTZ, -- Best-before
removal_date TIMESTAMPTZ, -- Fecha de retiro FEFO
alert_date TIMESTAMPTZ, -- Fecha de alerta
-- Control de alertas
expiry_alerted BOOLEAN NOT NULL DEFAULT false,
-- Propiedades dinámicas (heredadas del producto)
lot_properties JSONB DEFAULT '{}',
-- Cantidad total (calculada desde quants)
product_qty DECIMAL(20,6) GENERATED ALWAYS AS (
COALESCE((
SELECT SUM(quantity)
FROM inventory.quants q
JOIN inventory.locations l ON q.location_id = l.id
WHERE q.lot_id = id
AND l.usage IN ('internal', 'transit')
), 0)
) STORED,
-- Ubicación (si solo hay una)
location_id UUID REFERENCES inventory.locations(id),
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES core.users(id),
-- Constraints
CONSTRAINT uk_lot_product_company UNIQUE (product_id, name, company_id)
);
-- Índices
CREATE INDEX idx_lots_product ON inventory.lots(product_id);
CREATE INDEX idx_lots_expiration ON inventory.lots(expiration_date)
WHERE expiration_date IS NOT NULL;
CREATE INDEX idx_lots_removal ON inventory.lots(removal_date)
WHERE removal_date IS NOT NULL;
CREATE INDEX idx_lots_alert ON inventory.lots(alert_date)
WHERE alert_date IS NOT NULL AND NOT expiry_alerted;
CREATE INDEX idx_lots_name_trgm ON inventory.lots USING GIN (name gin_trgm_ops);
2.2.3 inventory.quants (extensión)
-- Agregar campos de lote a quants existentes
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
lot_id UUID REFERENCES inventory.lots(id);
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
in_date TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- Fecha de remoción para FEFO (heredada del lote)
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
removal_date TIMESTAMPTZ;
-- Indicador de duplicado (solo para seriales)
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
sn_duplicated BOOLEAN GENERATED ALWAYS AS (
CASE
WHEN lot_id IS NOT NULL AND
(SELECT tracking FROM products.products WHERE id = product_id) = 'serial' AND
(SELECT COUNT(*) FROM inventory.quants q2
WHERE q2.lot_id = lot_id
AND q2.quantity > 0) > 1
THEN true
ELSE false
END
) STORED;
-- Constraint de unicidad para quants
ALTER TABLE inventory.quants DROP CONSTRAINT IF EXISTS uk_quant_composite;
ALTER TABLE inventory.quants ADD CONSTRAINT uk_quant_composite
UNIQUE (product_id, location_id, lot_id, package_id, owner_id);
-- Índices optimizados
CREATE INDEX idx_quants_lot ON inventory.quants(lot_id)
WHERE lot_id IS NOT NULL;
CREATE INDEX idx_quants_fefo ON inventory.quants(product_id, location_id, removal_date, in_date)
WHERE quantity > 0;
CREATE INDEX idx_quants_fifo ON inventory.quants(product_id, location_id, in_date)
WHERE quantity > 0;
2.2.4 inventory.move_lines (extensión)
-- Agregar campos de lote a líneas de movimiento
ALTER TABLE inventory.move_lines ADD COLUMN IF NOT EXISTS
lot_id UUID REFERENCES inventory.lots(id);
ALTER TABLE inventory.move_lines ADD COLUMN IF NOT EXISTS
lot_name VARCHAR(128); -- Para creación on-the-fly
ALTER TABLE inventory.move_lines ADD COLUMN IF NOT EXISTS
tracking VARCHAR(16); -- Copia del producto
-- Tabla de relación para trazabilidad de manufactura
CREATE TABLE inventory.move_line_consume_rel (
consume_line_id UUID NOT NULL REFERENCES inventory.move_lines(id) ON DELETE CASCADE,
produce_line_id UUID NOT NULL REFERENCES inventory.move_lines(id) ON DELETE CASCADE,
PRIMARY KEY (consume_line_id, produce_line_id)
);
-- Índices
CREATE INDEX idx_move_lines_lot ON inventory.move_lines(lot_id)
WHERE lot_id IS NOT NULL;
CREATE INDEX idx_move_lines_lot_name ON inventory.move_lines(lot_name)
WHERE lot_name IS NOT NULL;
CREATE INDEX idx_consume_rel_consume ON inventory.move_line_consume_rel(consume_line_id);
CREATE INDEX idx_consume_rel_produce ON inventory.move_line_consume_rel(produce_line_id);
2.2.5 inventory.removal_strategies
CREATE TABLE inventory.removal_strategies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
name VARCHAR(64) NOT NULL,
code VARCHAR(16) NOT NULL UNIQUE
CHECK (code IN ('fifo', 'lifo', 'fefo', 'closest')),
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true
);
-- Datos iniciales
INSERT INTO inventory.removal_strategies (name, code, description) VALUES
('First In, First Out', 'fifo', 'El stock más antiguo sale primero'),
('Last In, First Out', 'lifo', 'El stock más reciente sale primero'),
('First Expiry, First Out', 'fefo', 'El stock que caduca primero sale primero'),
('Closest Location', 'closest', 'El stock de ubicación más cercana sale primero');
-- Agregar estrategia a productos/categorías/ubicaciones
ALTER TABLE products.product_categories ADD COLUMN IF NOT EXISTS
removal_strategy_id UUID REFERENCES inventory.removal_strategies(id);
ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS
removal_strategy_id UUID REFERENCES inventory.removal_strategies(id);
3. Algoritmos de Trazabilidad
3.1 Trazabilidad Ascendente (Upstream)
from typing import List, Set, Optional
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass
class TraceabilityLine:
"""Línea de resultado de trazabilidad."""
move_line_id: UUID
lot_id: UUID
lot_name: str
product_id: UUID
product_name: str
quantity: float
date: datetime
location_from: str
location_to: str
reference: str
reference_type: str # 'picking', 'production', 'adjustment'
level: int # Nivel en el árbol de trazabilidad
class TraceabilityService:
"""
Servicio de trazabilidad para lotes y números de serie.
Implementa búsqueda bidireccional con soporte para manufactura.
"""
def __init__(self, db_session):
self.db = db_session
def get_upstream_traceability(
self,
lot_id: UUID,
max_depth: int = 10
) -> List[TraceabilityLine]:
"""
Obtiene la trazabilidad ascendente (¿de dónde vino este lote?).
Sigue la cadena:
- Movimientos de origen (move_orig_ids) para MTO
- Movimientos entrantes a la ubicación para MTS
- Líneas consumidas en manufactura
Args:
lot_id: ID del lote a trazar
max_depth: Profundidad máxima de búsqueda
Returns:
Lista de TraceabilityLine ordenadas por fecha descendente
"""
lot = self._get_lot(lot_id)
if not lot:
return []
# Obtener líneas de movimiento del lote
initial_lines = self._get_lot_move_lines(lot_id)
if not initial_lines:
return []
result = []
lines_seen: Set[UUID] = set()
lines_todo = list(initial_lines)
current_level = 0
while lines_todo and current_level < max_depth:
current_level += 1
next_level_lines = []
for line_id in lines_todo:
if line_id in lines_seen:
continue
lines_seen.add(line_id)
line = self._get_move_line_detail(line_id)
if not line:
continue
# Agregar a resultado
result.append(TraceabilityLine(
move_line_id=line['id'],
lot_id=line['lot_id'],
lot_name=line['lot_name'],
product_id=line['product_id'],
product_name=line['product_name'],
quantity=line['quantity'],
date=line['date'],
location_from=line['location_src'],
location_to=line['location_dest'],
reference=line['reference'],
reference_type=line['reference_type'],
level=current_level
))
# Buscar líneas de origen
upstream_lines = self._find_upstream_lines(line, lot_id)
for upstream_id in upstream_lines:
if upstream_id not in lines_seen:
next_level_lines.append(upstream_id)
lines_todo = next_level_lines
return sorted(result, key=lambda x: x.date, reverse=True)
def _find_upstream_lines(
self,
line: dict,
lot_id: UUID
) -> List[UUID]:
"""
Encuentra líneas de movimiento anteriores.
"""
upstream_ids = []
# Caso 1: MTO - Seguir cadena de movimientos origen
if line.get('move_orig_ids'):
query = """
SELECT ml.id
FROM inventory.move_lines ml
JOIN inventory.moves m ON ml.move_id = m.id
WHERE m.id = ANY(:move_orig_ids)
AND ml.lot_id = :lot_id
AND ml.state = 'done'
"""
result = self.db.execute(query, {
'move_orig_ids': line['move_orig_ids'],
'lot_id': lot_id
})
upstream_ids.extend([r['id'] for r in result])
# Caso 2: MTS - Buscar movimientos entrantes a la ubicación origen
elif line.get('location_src_usage') in ('internal', 'transit'):
query = """
SELECT ml.id
FROM inventory.move_lines ml
WHERE ml.product_id = :product_id
AND ml.lot_id = :lot_id
AND ml.location_dest_id = :location_id
AND ml.id != :current_id
AND ml.date <= :date
AND ml.state = 'done'
ORDER BY ml.date DESC
"""
result = self.db.execute(query, {
'product_id': line['product_id'],
'lot_id': lot_id,
'location_id': line['location_src_id'],
'current_id': line['id'],
'date': line['date']
})
upstream_ids.extend([r['id'] for r in result])
# Caso 3: Manufactura - Buscar líneas consumidas
if line.get('consume_line_ids'):
upstream_ids.extend(line['consume_line_ids'])
return upstream_ids
def get_downstream_traceability(
self,
lot_id: UUID,
max_depth: int = 10
) -> List[TraceabilityLine]:
"""
Obtiene la trazabilidad descendente (¿a dónde fue este lote?).
Utiliza BFS iterativo para manejar grafos de manufactura.
Args:
lot_id: ID del lote a trazar
max_depth: Profundidad máxima de búsqueda
Returns:
Lista de TraceabilityLine con deliveries y producciones
"""
lot = self._get_lot(lot_id)
if not lot:
return []
# Usar algoritmo BFS para grafos de manufactura
all_lot_ids: Set[UUID] = {lot_id}
barren_lines: dict = {} # lot_id -> set of line_ids (hojas)
parent_map: dict = {} # child_lot_id -> set of parent_lot_ids
queue = [lot_id]
result = []
level = 0
while queue and level < max_depth:
level += 1
next_queue = []
# Obtener todas las líneas salientes para lotes en cola
query = """
SELECT ml.*, l.usage as location_dest_usage
FROM inventory.move_lines ml
JOIN inventory.locations l ON ml.location_dest_id = l.id
WHERE ml.lot_id = ANY(:lot_ids)
AND ml.state = 'done'
AND l.usage IN ('customer', 'internal', 'transit', 'production')
"""
lines = self.db.execute(query, {'lot_ids': list(queue)}).fetchall()
for line in lines:
line_lot_id = line['lot_id']
# Buscar si esta línea produjo otros lotes
produce_query = """
SELECT DISTINCT ml2.lot_id
FROM inventory.move_line_consume_rel rel
JOIN inventory.move_lines ml2 ON rel.produce_line_id = ml2.id
WHERE rel.consume_line_id = :line_id
AND ml2.lot_id IS NOT NULL
"""
produce_result = self.db.execute(
produce_query, {'line_id': line['id']}
).fetchall()
produce_lot_ids = [r['lot_id'] for r in produce_result]
if produce_lot_ids:
# Este lote fue usado para producir otros
for child_lot_id in produce_lot_ids:
if child_lot_id not in parent_map:
parent_map[child_lot_id] = set()
parent_map[child_lot_id].add(line_lot_id)
if child_lot_id not in all_lot_ids:
all_lot_ids.add(child_lot_id)
next_queue.append(child_lot_id)
else:
# Línea hoja (delivery final o consumo)
if line_lot_id not in barren_lines:
barren_lines[line_lot_id] = set()
barren_lines[line_lot_id].add(line['id'])
# Agregar a resultado
result.append(TraceabilityLine(
move_line_id=line['id'],
lot_id=line['lot_id'],
lot_name=line['lot_name'],
product_id=line['product_id'],
product_name=line['product_name'],
quantity=line['quantity'],
date=line['date'],
location_from=line['location_src_name'],
location_to=line['location_dest_name'],
reference=line['reference'],
reference_type=self._get_reference_type(line),
level=level
))
queue = next_queue
return sorted(result, key=lambda x: x.date)
def get_full_traceability_report(
self,
lot_id: UUID
) -> dict:
"""
Genera un reporte completo de trazabilidad bidireccional.
"""
lot = self._get_lot(lot_id)
if not lot:
return None
upstream = self.get_upstream_traceability(lot_id)
downstream = self.get_downstream_traceability(lot_id)
# Obtener entregas (deliveries) finales
deliveries = self._get_lot_deliveries(lot_id)
return {
'lot': {
'id': lot_id,
'name': lot['name'],
'product_id': lot['product_id'],
'product_name': lot['product_name'],
'expiration_date': lot['expiration_date'],
'current_qty': lot['product_qty']
},
'upstream': [self._line_to_dict(l) for l in upstream],
'downstream': [self._line_to_dict(l) for l in downstream],
'deliveries': deliveries,
'summary': {
'total_received': sum(l.quantity for l in upstream if l.level == 1),
'total_shipped': sum(l.quantity for l in downstream
if l.reference_type == 'delivery'),
'total_consumed': sum(l.quantity for l in downstream
if l.reference_type == 'production'),
'upstream_levels': max((l.level for l in upstream), default=0),
'downstream_levels': max((l.level for l in downstream), default=0)
}
}
3.2 Estrategias de Salida (FIFO/LIFO/FEFO)
from enum import Enum
from typing import List, Optional
from decimal import Decimal
class RemovalStrategy(Enum):
FIFO = 'fifo'
LIFO = 'lifo'
FEFO = 'fefo'
CLOSEST = 'closest'
class QuantGatherService:
"""
Servicio para seleccionar quants según estrategia de salida.
"""
STRATEGY_ORDER = {
RemovalStrategy.FIFO: 'in_date ASC, id ASC',
RemovalStrategy.LIFO: 'in_date DESC, id DESC',
RemovalStrategy.FEFO: 'removal_date ASC NULLS LAST, in_date ASC, id ASC',
RemovalStrategy.CLOSEST: None # Ordenamiento especial
}
def __init__(self, db_session):
self.db = db_session
def gather_quants(
self,
product_id: UUID,
location_id: UUID,
quantity_needed: Decimal,
lot_id: Optional[UUID] = None,
package_id: Optional[UUID] = None,
owner_id: Optional[UUID] = None,
strict: bool = False,
exclude_expired: bool = True
) -> List[dict]:
"""
Recolecta quants para satisfacer una cantidad requerida.
Args:
product_id: ID del producto
location_id: ID de la ubicación origen
quantity_needed: Cantidad a recolectar
lot_id: Filtrar por lote específico (opcional)
package_id: Filtrar por paquete (opcional)
owner_id: Filtrar por propietario (opcional)
strict: Si True, solo coincidencias exactas
exclude_expired: Excluir lotes caducados
Returns:
Lista de quants con cantidades a tomar
"""
# Determinar estrategia de salida
strategy = self._get_removal_strategy(product_id, location_id)
# Construir dominio de búsqueda
domain_params = {
'product_id': product_id,
'location_id': location_id
}
where_clauses = [
"q.product_id = :product_id",
"q.location_id = :location_id",
"q.quantity > q.reserved_quantity"
]
if lot_id:
where_clauses.append("q.lot_id = :lot_id")
domain_params['lot_id'] = lot_id
elif strict:
where_clauses.append("q.lot_id IS NULL")
if package_id:
where_clauses.append("q.package_id = :package_id")
domain_params['package_id'] = package_id
elif strict:
where_clauses.append("q.package_id IS NULL")
if owner_id:
where_clauses.append("q.owner_id = :owner_id")
domain_params['owner_id'] = owner_id
elif strict:
where_clauses.append("q.owner_id IS NULL")
# Excluir caducados para FEFO
if exclude_expired and strategy == RemovalStrategy.FEFO:
where_clauses.append("""
(l.expiration_date IS NULL OR l.expiration_date > NOW())
""")
# Construir ORDER BY según estrategia
order_clause = self.STRATEGY_ORDER.get(strategy)
if strategy == RemovalStrategy.CLOSEST:
order_clause = self._build_closest_order(location_id)
# Query principal
query = f"""
SELECT
q.id,
q.product_id,
q.lot_id,
l.name as lot_name,
l.expiration_date,
l.removal_date,
q.location_id,
q.package_id,
q.owner_id,
q.quantity,
q.reserved_quantity,
(q.quantity - q.reserved_quantity) as available_quantity,
q.in_date
FROM inventory.quants q
LEFT JOIN inventory.lots l ON q.lot_id = l.id
WHERE {' AND '.join(where_clauses)}
ORDER BY
-- Preferir sin lote sobre con lote (para productos sin tracking)
(CASE WHEN q.lot_id IS NULL THEN 0 ELSE 1 END),
{order_clause}
"""
quants = self.db.execute(query, domain_params).fetchall()
# Seleccionar quants hasta cubrir cantidad necesaria
result = []
remaining = quantity_needed
for quant in quants:
if remaining <= 0:
break
available = Decimal(str(quant['available_quantity']))
take_qty = min(available, remaining)
result.append({
'quant_id': quant['id'],
'lot_id': quant['lot_id'],
'lot_name': quant['lot_name'],
'quantity_to_take': float(take_qty),
'available_quantity': float(available),
'in_date': quant['in_date'],
'expiration_date': quant['expiration_date'],
'removal_date': quant['removal_date']
})
remaining -= take_qty
return result
def _get_removal_strategy(
self,
product_id: UUID,
location_id: UUID
) -> RemovalStrategy:
"""
Determina la estrategia de salida según jerarquía:
Producto > Categoría > Ubicación > Ubicación Padre
"""
# Buscar en producto/categoría
query = """
SELECT
COALESCE(
pc.removal_strategy_id,
(SELECT removal_strategy_id FROM inventory.locations WHERE id = :location_id)
) as strategy_id
FROM products.products p
LEFT JOIN products.product_categories pc ON p.category_id = pc.id
WHERE p.id = :product_id
"""
result = self.db.execute(query, {
'product_id': product_id,
'location_id': location_id
}).fetchone()
if result and result['strategy_id']:
strategy_query = """
SELECT code FROM inventory.removal_strategies
WHERE id = :strategy_id
"""
strategy = self.db.execute(
strategy_query, {'strategy_id': result['strategy_id']}
).fetchone()
if strategy:
return RemovalStrategy(strategy['code'])
# Default: FIFO
return RemovalStrategy.FIFO
def _build_closest_order(self, location_id: UUID) -> str:
"""
Construye ORDER BY para estrategia 'closest'.
Ordena por proximidad jerárquica de ubicación.
"""
return """
(SELECT complete_name FROM inventory.locations WHERE id = q.location_id) ASC,
q.id DESC
"""
4. Gestión de Fechas de Caducidad
4.1 Cálculo Automático de Fechas
from datetime import datetime, timedelta
class LotExpirationService:
"""
Servicio para gestionar fechas de caducidad de lotes.
"""
def __init__(self, db_session):
self.db = db_session
def compute_expiration_dates(
self,
lot_id: UUID,
reception_date: Optional[datetime] = None
) -> dict:
"""
Calcula todas las fechas de caducidad basándose en el producto.
Args:
lot_id: ID del lote
reception_date: Fecha de recepción (default: now)
Returns:
Dict con fechas calculadas
"""
# Obtener configuración del producto
query = """
SELECT
p.use_expiration_date,
p.expiration_time,
p.use_time,
p.removal_time,
p.alert_time
FROM inventory.lots l
JOIN products.products p ON l.product_id = p.id
WHERE l.id = :lot_id
"""
config = self.db.execute(query, {'lot_id': lot_id}).fetchone()
if not config or not config['use_expiration_date']:
return {}
base_date = reception_date or datetime.now()
# Calcular fechas
expiration_date = base_date + timedelta(days=config['expiration_time'])
dates = {
'expiration_date': expiration_date
}
if config['use_time']:
dates['use_date'] = expiration_date - timedelta(days=config['use_time'])
if config['removal_time']:
dates['removal_date'] = expiration_date - timedelta(days=config['removal_time'])
if config['alert_time']:
dates['alert_date'] = expiration_date - timedelta(days=config['alert_time'])
return dates
def update_lot_dates(self, lot_id: UUID, dates: dict):
"""Actualiza las fechas de un lote."""
update_fields = []
params = {'lot_id': lot_id}
for field in ['expiration_date', 'use_date', 'removal_date', 'alert_date']:
if field in dates:
update_fields.append(f"{field} = :{field}")
params[field] = dates[field]
if update_fields:
query = f"""
UPDATE inventory.lots
SET {', '.join(update_fields)},
updated_at = NOW()
WHERE id = :lot_id
"""
self.db.execute(query, params)
def check_expiration_alerts(self) -> List[dict]:
"""
Verifica lotes que han alcanzado su fecha de alerta.
Ejecutar como job programado (cron).
Returns:
Lista de lotes que requieren alerta
"""
query = """
SELECT
l.id,
l.name as lot_name,
l.product_id,
p.name as product_name,
l.expiration_date,
l.alert_date,
l.removal_date,
SUM(q.quantity) as stock_qty
FROM inventory.lots l
JOIN products.products p ON l.product_id = p.id
JOIN inventory.quants q ON q.lot_id = l.id
JOIN inventory.locations loc ON q.location_id = loc.id
WHERE l.alert_date <= NOW()
AND l.expiry_alerted = false
AND loc.usage = 'internal'
GROUP BY l.id, p.id
HAVING SUM(q.quantity) > 0
ORDER BY l.removal_date ASC NULLS LAST
"""
lots = self.db.execute(query).fetchall()
# Marcar como alertados
if lots:
lot_ids = [lot['id'] for lot in lots]
self.db.execute("""
UPDATE inventory.lots
SET expiry_alerted = true,
updated_at = NOW()
WHERE id = ANY(:lot_ids)
""", {'lot_ids': lot_ids})
return [dict(lot) for lot in lots]
def get_expiring_lots(
self,
days_ahead: int = 30,
location_id: Optional[UUID] = None
) -> List[dict]:
"""
Obtiene lotes próximos a caducar.
Args:
days_ahead: Días hacia adelante para buscar
location_id: Filtrar por ubicación
Returns:
Lista de lotes con stock próximo a caducar
"""
params = {
'threshold_date': datetime.now() + timedelta(days=days_ahead)
}
location_filter = ""
if location_id:
location_filter = "AND q.location_id = :location_id"
params['location_id'] = location_id
query = f"""
SELECT
l.id,
l.name as lot_name,
l.product_id,
p.name as product_name,
p.default_code as sku,
l.expiration_date,
l.removal_date,
EXTRACT(DAY FROM l.expiration_date - NOW()) as days_until_expiry,
SUM(q.quantity) as stock_qty,
array_agg(DISTINCT loc.name) as locations
FROM inventory.lots l
JOIN products.products p ON l.product_id = p.id
JOIN inventory.quants q ON q.lot_id = l.id
JOIN inventory.locations loc ON q.location_id = loc.id
WHERE l.expiration_date <= :threshold_date
AND l.expiration_date > NOW()
AND loc.usage = 'internal'
{location_filter}
GROUP BY l.id, p.id
HAVING SUM(q.quantity) > 0
ORDER BY l.expiration_date ASC
"""
return self.db.execute(query, params).fetchall()
5. Validaciones
5.1 Validaciones de Lote/Serie
class LotValidationService:
"""
Servicio de validación para lotes y números de serie.
"""
def __init__(self, db_session):
self.db = db_session
def validate_lot_assignment(
self,
product_id: UUID,
lot_id: Optional[UUID],
lot_name: Optional[str],
quantity: Decimal,
move_line_ids_to_exclude: List[UUID] = None
) -> dict:
"""
Valida la asignación de lote a un movimiento.
Returns:
Dict con 'valid', 'errors', 'warnings'
"""
result = {'valid': True, 'errors': [], 'warnings': []}
# Obtener tipo de tracking del producto
product = self._get_product(product_id)
if not product:
result['valid'] = False
result['errors'].append("Producto no encontrado")
return result
tracking = product['tracking']
# Validación 1: Producto sin tracking no debe tener lote
if tracking == 'none':
if lot_id or lot_name:
result['warnings'].append(
"Producto sin tracking, el lote será ignorado"
)
return result
# Validación 2: Producto con tracking requiere lote
if tracking in ('lot', 'serial') and not lot_id and not lot_name:
result['valid'] = False
result['errors'].append(
f"Producto con tracking '{tracking}' requiere lote/serie"
)
return result
# Validación 3: Serial requiere cantidad = 1
if tracking == 'serial' and quantity != Decimal('1'):
result['valid'] = False
result['errors'].append(
"Productos con tracking 'serial' deben tener cantidad = 1"
)
# Validación 4: Serial no puede estar duplicado en mismo picking
if tracking == 'serial' and (lot_id or lot_name):
duplicates = self._check_serial_duplicates(
product_id=product_id,
lot_id=lot_id,
lot_name=lot_name,
exclude_line_ids=move_line_ids_to_exclude
)
if duplicates:
result['valid'] = False
result['errors'].append(
f"Número de serie duplicado en el mismo movimiento"
)
# Validación 5: Lote debe pertenecer al producto correcto
if lot_id:
lot = self._get_lot(lot_id)
if lot and lot['product_id'] != product_id:
result['valid'] = False
result['errors'].append(
f"Lote {lot['name']} no corresponde al producto"
)
# Validación 6: Verificar stock existente de serial
if tracking == 'serial' and (lot_id or lot_name):
existing_stock = self._check_serial_in_stock(
product_id=product_id,
lot_id=lot_id,
lot_name=lot_name
)
if existing_stock:
result['warnings'].append(
f"Número de serie ya existe en stock en: {existing_stock['locations']}"
)
return result
def validate_lot_uniqueness(
self,
product_id: UUID,
lot_name: str,
company_id: UUID,
lot_id_to_exclude: Optional[UUID] = None
) -> bool:
"""
Valida que no exista otro lote con el mismo nombre para el producto.
"""
query = """
SELECT id FROM inventory.lots
WHERE product_id = :product_id
AND name = :lot_name
AND company_id = :company_id
AND (:exclude_id IS NULL OR id != :exclude_id)
"""
result = self.db.execute(query, {
'product_id': product_id,
'lot_name': lot_name,
'company_id': company_id,
'exclude_id': lot_id_to_exclude
}).fetchone()
return result is None
def _check_serial_duplicates(
self,
product_id: UUID,
lot_id: Optional[UUID],
lot_name: Optional[str],
exclude_line_ids: List[UUID] = None
) -> bool:
"""Verifica si el serial está duplicado en líneas pendientes."""
params = {'product_id': product_id}
conditions = ["ml.product_id = :product_id", "ml.state NOT IN ('done', 'cancel')"]
if lot_id:
conditions.append("ml.lot_id = :lot_id")
params['lot_id'] = lot_id
elif lot_name:
conditions.append("ml.lot_name = :lot_name")
params['lot_name'] = lot_name
if exclude_line_ids:
conditions.append("ml.id != ALL(:exclude_ids)")
params['exclude_ids'] = exclude_line_ids
query = f"""
SELECT COUNT(*) as count
FROM inventory.move_lines ml
WHERE {' AND '.join(conditions)}
"""
result = self.db.execute(query, params).fetchone()
return result['count'] > 0
def _check_serial_in_stock(
self,
product_id: UUID,
lot_id: Optional[UUID],
lot_name: Optional[str]
) -> Optional[dict]:
"""Verifica si el serial tiene stock existente."""
if lot_id:
lot_filter = "l.id = :lot_id"
params = {'lot_id': lot_id}
else:
lot_filter = "l.name = :lot_name AND l.product_id = :product_id"
params = {'lot_name': lot_name, 'product_id': product_id}
query = f"""
SELECT
l.id,
l.name,
SUM(q.quantity) as total_qty,
array_agg(DISTINCT loc.name) as locations
FROM inventory.lots l
JOIN inventory.quants q ON q.lot_id = l.id
JOIN inventory.locations loc ON q.location_id = loc.id
WHERE {lot_filter}
AND loc.usage IN ('internal', 'transit', 'customer')
AND q.quantity > 0
GROUP BY l.id
"""
return self.db.execute(query, params).fetchone()
6. API REST
6.1 Endpoints
openapi: 3.0.3
info:
title: Lot/Serial Traceability API
version: 1.0.0
paths:
/api/v1/inventory/lots:
get:
summary: Listar lotes
parameters:
- name: product_id
in: query
schema:
type: string
format: uuid
- name: search
in: query
schema:
type: string
description: Búsqueda por nombre de lote
- name: expiring_within_days
in: query
schema:
type: integer
- name: has_stock
in: query
schema:
type: boolean
responses:
'200':
description: Lista de lotes
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Lot'
post:
summary: Crear lote
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LotCreate'
responses:
'201':
description: Lote creado
content:
application/json:
schema:
$ref: '#/components/schemas/Lot'
/api/v1/inventory/lots/{id}:
get:
summary: Obtener lote
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Detalle del lote
content:
application/json:
schema:
$ref: '#/components/schemas/LotDetail'
patch:
summary: Actualizar lote
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LotUpdate'
responses:
'200':
description: Lote actualizado
/api/v1/inventory/lots/{id}/traceability:
get:
summary: Obtener trazabilidad completa
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: direction
in: query
schema:
type: string
enum: [upstream, downstream, both]
default: both
- name: max_depth
in: query
schema:
type: integer
default: 10
responses:
'200':
description: Reporte de trazabilidad
content:
application/json:
schema:
$ref: '#/components/schemas/TraceabilityReport'
/api/v1/inventory/lots/{id}/deliveries:
get:
summary: Obtener entregas del lote
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Lista de entregas
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Delivery'
/api/v1/inventory/lots/generate-names:
post:
summary: Generar nombres de lote secuenciales
requestBody:
content:
application/json:
schema:
type: object
properties:
first_lot:
type: string
example: "LOT-2025-0001"
count:
type: integer
example: 10
required:
- first_lot
- count
responses:
'200':
description: Nombres generados
content:
application/json:
schema:
type: array
items:
type: string
/api/v1/inventory/lots/expiring:
get:
summary: Obtener lotes próximos a caducar
parameters:
- name: days_ahead
in: query
schema:
type: integer
default: 30
- name: location_id
in: query
schema:
type: string
format: uuid
responses:
'200':
description: Lotes por caducar
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ExpiringLot'
/api/v1/inventory/lots/{id}/recall:
post:
summary: Iniciar recall de lote
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
reason:
type: string
notify_customers:
type: boolean
default: true
required:
- reason
responses:
'200':
description: Recall iniciado
content:
application/json:
schema:
$ref: '#/components/schemas/RecallResult'
/api/v1/inventory/removal-strategies:
get:
summary: Listar estrategias de salida
responses:
'200':
description: Estrategias disponibles
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/RemovalStrategy'
components:
schemas:
Lot:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
ref:
type: string
product_id:
type: string
format: uuid
product_name:
type: string
expiration_date:
type: string
format: date-time
use_date:
type: string
format: date-time
removal_date:
type: string
format: date-time
alert_date:
type: string
format: date-time
product_qty:
type: number
lot_properties:
type: object
LotDetail:
allOf:
- $ref: '#/components/schemas/Lot'
- type: object
properties:
quants:
type: array
items:
type: object
properties:
location_id:
type: string
format: uuid
location_name:
type: string
quantity:
type: number
reserved_quantity:
type: number
LotCreate:
type: object
properties:
name:
type: string
ref:
type: string
product_id:
type: string
format: uuid
expiration_date:
type: string
format: date-time
lot_properties:
type: object
required:
- name
- product_id
LotUpdate:
type: object
properties:
ref:
type: string
expiration_date:
type: string
format: date-time
lot_properties:
type: object
TraceabilityReport:
type: object
properties:
lot:
$ref: '#/components/schemas/Lot'
upstream:
type: array
items:
$ref: '#/components/schemas/TraceabilityLine'
downstream:
type: array
items:
$ref: '#/components/schemas/TraceabilityLine'
deliveries:
type: array
items:
$ref: '#/components/schemas/Delivery'
summary:
type: object
properties:
total_received:
type: number
total_shipped:
type: number
total_consumed:
type: number
upstream_levels:
type: integer
downstream_levels:
type: integer
TraceabilityLine:
type: object
properties:
move_line_id:
type: string
format: uuid
lot_id:
type: string
format: uuid
lot_name:
type: string
product_id:
type: string
format: uuid
product_name:
type: string
quantity:
type: number
date:
type: string
format: date-time
location_from:
type: string
location_to:
type: string
reference:
type: string
reference_type:
type: string
enum: [picking, production, adjustment, scrap]
level:
type: integer
ExpiringLot:
type: object
properties:
id:
type: string
format: uuid
lot_name:
type: string
product_id:
type: string
format: uuid
product_name:
type: string
sku:
type: string
expiration_date:
type: string
format: date-time
days_until_expiry:
type: integer
stock_qty:
type: number
locations:
type: array
items:
type: string
RecallResult:
type: object
properties:
success:
type: boolean
recall_id:
type: string
format: uuid
affected_deliveries:
type: integer
return_pickings_created:
type: integer
customers_notified:
type: integer
RemovalStrategy:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
code:
type: string
enum: [fifo, lifo, fefo, closest]
description:
type: string
Delivery:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
partner_id:
type: string
format: uuid
partner_name:
type: string
date:
type: string
format: date-time
quantity:
type: number
state:
type: string
7. Integración con Código de Barras GS1
7.1 Formato GS1-128
class GS1BarcodeService:
"""
Servicio para generación y parseo de códigos de barras GS1.
"""
# Application Identifiers (AI) comunes
AI_CODES = {
'01': 'gtin', # Global Trade Item Number (14 dígitos)
'02': 'content', # Contenido del contenedor
'10': 'lot', # Número de lote
'11': 'prod_date', # Fecha de producción (YYMMDD)
'13': 'pack_date', # Fecha de empaque (YYMMDD)
'15': 'best_before', # Consumir preferentemente (YYMMDD)
'17': 'expiry_date', # Fecha de caducidad (YYMMDD)
'21': 'serial', # Número de serie
'30': 'var_count', # Cantidad variable
'310': 'net_weight_kg', # Peso neto en kg (6 decimales)
'37': 'count', # Cantidad de unidades
}
def generate_gs1_barcode(
self,
lot: dict,
quant: Optional[dict] = None,
include_dates: bool = True
) -> str:
"""
Genera código de barras GS1-128 para un lote.
Args:
lot: Información del lote
quant: Información del quant (para cantidad)
include_dates: Incluir fechas de caducidad
Returns:
String del código de barras GS1-128
"""
parts = []
# AI 10: Número de lote
parts.append(f"10{lot['name']}")
# AI 17: Fecha de caducidad
if include_dates and lot.get('expiration_date'):
exp_date = lot['expiration_date'].strftime('%y%m%d')
parts.append(f"17{exp_date}")
# AI 15: Best before date
if include_dates and lot.get('use_date'):
use_date = lot['use_date'].strftime('%y%m%d')
parts.append(f"15{use_date}")
# AI 21: Serial (si es tracking serial)
if lot.get('tracking') == 'serial':
parts.append(f"21{lot['name']}")
# AI 37: Cantidad
if quant and quant.get('quantity'):
parts.append(f"37{int(quant['quantity'])}")
return ''.join(parts)
def parse_gs1_barcode(self, barcode: str) -> dict:
"""
Parsea un código de barras GS1-128.
Returns:
Dict con los campos extraídos
"""
result = {}
i = 0
while i < len(barcode):
# Buscar AI de 2 dígitos
ai2 = barcode[i:i+2]
if ai2 in self.AI_CODES:
field = self.AI_CODES[ai2]
i += 2
if ai2 in ('11', '13', '15', '17'):
# Fechas: 6 dígitos YYMMDD
value = barcode[i:i+6]
result[field] = self._parse_date(value)
i += 6
elif ai2 == '10':
# Lote: variable hasta FNC1 o fin
end = self._find_end(barcode, i)
result[field] = barcode[i:end]
i = end
elif ai2 == '21':
# Serial: variable hasta FNC1 o fin
end = self._find_end(barcode, i)
result[field] = barcode[i:end]
i = end
elif ai2 == '30':
# Cantidad variable: hasta 8 dígitos
end = self._find_end(barcode, i, max_len=8)
result[field] = int(barcode[i:end])
i = end
elif ai2 == '37':
# Cantidad: hasta 8 dígitos
end = self._find_end(barcode, i, max_len=8)
result[field] = int(barcode[i:end])
i = end
continue
# Buscar AI de 3 dígitos
ai3 = barcode[i:i+3]
if ai3 in ('310', '311', '312', '313', '314', '315'):
# Peso con decimales
i += 4 # AI + dígito de decimales
result['weight'] = float(barcode[i:i+6]) / 1000000
i += 6
continue
# AI de 4 dígitos (GTIN)
ai4 = barcode[i:i+4]
if ai4.startswith('01'):
i += 2
result['gtin'] = barcode[i:i+14]
i += 14
continue
i += 1 # Avanzar si no se reconoce
return result
def _parse_date(self, yymmdd: str) -> datetime:
"""Parsea fecha YYMMDD a datetime."""
year = int(yymmdd[0:2])
month = int(yymmdd[2:4])
day = int(yymmdd[4:6])
# Asumir siglo 21 para años < 50
full_year = 2000 + year if year < 50 else 1900 + year
return datetime(full_year, month, day)
def _find_end(self, s: str, start: int, max_len: int = 20) -> int:
"""Encuentra el fin de un campo variable."""
# GS (Group Separator) = chr(29) o FNC1
for i in range(start, min(start + max_len, len(s))):
if s[i] == chr(29):
return i
return min(start + max_len, len(s))
8. Reglas de Negocio
8.1 Validaciones
| Regla | Descripción | Acción |
|---|---|---|
| RN-001 | Serial debe ser único por producto | Error |
| RN-002 | Cantidad de serial debe ser 1.0 | Error |
| RN-003 | Lote debe pertenecer al producto | Error |
| RN-004 | Producto con tracking requiere lote | Error en confirmación |
| RN-005 | Lote caducado no puede enviarse | Warning/Block configurable |
| RN-006 | FEFO: priorizar removal_date | Automático |
8.2 Comportamientos Automáticos
| Trigger | Acción |
|---|---|
| Crear lote | Calcular fechas de caducidad |
| Recibir con lote nuevo | Crear lote automáticamente |
| Alcanzar alert_date | Crear actividad de alerta |
| Validar picking | Resolver lot_name → lot_id |
| Manufactura completada | Crear relaciones consume/produce |
9. Consideraciones de Rendimiento
9.1 Índices Optimizados
-- Búsqueda de lotes por nombre (trigram)
CREATE INDEX idx_lots_name_trgm ON inventory.lots USING GIN (name gin_trgm_ops);
-- Quants para FEFO
CREATE INDEX idx_quants_fefo_lookup
ON inventory.quants(product_id, location_id, removal_date NULLS LAST, in_date)
WHERE quantity > reserved_quantity;
-- Trazabilidad
CREATE INDEX idx_move_lines_traceability
ON inventory.move_lines(lot_id, product_id, date)
WHERE state = 'done';
-- Alertas de caducidad
CREATE INDEX idx_lots_expiry_alert
ON inventory.lots(alert_date)
WHERE alert_date IS NOT NULL
AND expiry_alerted = false;
9.2 Caching
# Cache de estrategias de salida por producto/ubicación
REMOVAL_STRATEGY_CACHE = TTLCache(maxsize=1000, ttl=300)
# Cache de quants para operaciones masivas
QUANTS_CACHE_KEY = (product_id, location_id, lot_id, package_id, owner_id)
10. Referencias
- Odoo stock.lot model
- Odoo product_expiry module
- GS1 General Specifications (https://www.gs1.org/)
- ISO 22005:2007 - Traceability in feed and food chain
Documento generado para ERP-SUITE Versión: 1.0 Fecha: 2025-01-09