79 KiB
79 KiB
SPEC-INVENTARIOS-CICLICOS: Conteo Cíclico y Ajustes de Inventario
Metadata
- Código: SPEC-INVENTARIOS-CICLICOS
- Versión: 1.0.0
- Fecha: 2025-01-15
- Gap Relacionado: GAP-MGN-005-003
- Módulo: MGN-005 (Inventario)
- Prioridad: P1
- Story Points: 13
- Odoo Referencia: stock (stock_quant inventory mode), stock_account
1. Resumen Ejecutivo
1.1 Descripción del Gap
El sistema de inventario actual permite ajustes manuales pero carece de funcionalidad para conteo cíclico programado, que permite verificar el inventario de forma continua en lugar de realizar conteos completos anuales que paralizan operaciones.
1.2 Impacto en el Negocio
| Aspecto | Sin Conteo Cíclico | Con Conteo Cíclico |
|---|---|---|
| Frecuencia de conteo | Anual (paralizante) | Continuo (sin interrupción) |
| Precisión | Degradada con el tiempo | Mantenida constantemente |
| Detección errores | Tardía (fin de año) | Temprana (días/semanas) |
| Recursos | Masivos en período corto | Distribuidos en el tiempo |
| Cumplimiento | Inventario físico anual | Conteos parciales continuos |
| Productos críticos | Mismo trato que otros | Conteo más frecuente (ABC) |
1.3 Objetivos de la Especificación
- Implementar modelo basado en quants con modo de inventario
- Configurar frecuencias de conteo por ubicación
- Soportar clasificación ABC para priorización de conteos
- Gestionar conflictos cuando el inventario cambia durante el conteo
- Integrar con valoración de inventario y contabilidad
- Automatizar programación de próximos conteos
2. Arquitectura de Datos
2.1 Diagrama del Sistema
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE INVENTARIOS CÍCLICOS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CONFIGURACIÓN DE FRECUENCIA │ │
│ │ │ │
│ │ Ubicación A Ubicación B Ubicación C │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Freq: 7d │ │ Freq: 30d│ │ Freq: 90d│ │ │
│ │ │ ABC: A │ │ ABC: B │ │ ABC: C │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ QUANTS (Stock Actual) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ product_id │ location_id │ quantity │ inventory_quantity │ │ │
│ │ │ lot_id │ package_id │ reserved │ inventory_diff │ │ │
│ │ │ owner_id │ user_id │ │ inventory_date │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ FLUJO DE AJUSTE │ │
│ │ │ │
│ │ Asignar → Contar → Validar → Aplicar │ │
│ │ usuario cantidad conflictos movimientos │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ IMPACTO EN VALORACIÓN │ │
│ │ │ │
│ │ stock_moves → valuation_layers → journal_entries │ │
│ │ (is_inventory) (cost tracking) (accounting) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.2 Definición de Tablas
-- =============================================================================
-- SCHEMA: inventory
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Extensión: locations (Agregar configuración de conteo cíclico)
-- -----------------------------------------------------------------------------
ALTER TABLE inventory.locations
ADD COLUMN IF NOT EXISTS cyclic_inventory_frequency INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_inventory_date DATE,
ADD COLUMN IF NOT EXISTS next_inventory_date DATE GENERATED ALWAYS AS (
CASE
WHEN cyclic_inventory_frequency > 0 AND last_inventory_date IS NOT NULL
THEN last_inventory_date + (cyclic_inventory_frequency || ' days')::INTERVAL
WHEN cyclic_inventory_frequency > 0
THEN CURRENT_DATE
ELSE NULL
END
) STORED,
ADD COLUMN IF NOT EXISTS abc_classification VARCHAR(1) DEFAULT 'C';
COMMENT ON COLUMN inventory.locations.cyclic_inventory_frequency IS
'Días entre conteos cíclicos. 0 = deshabilitado';
COMMENT ON COLUMN inventory.locations.abc_classification IS
'Clasificación ABC: A=Alta rotación, B=Media, C=Baja';
-- Restricción para clasificación ABC
ALTER TABLE inventory.locations
ADD CONSTRAINT chk_abc_classification
CHECK (abc_classification IN ('A', 'B', 'C'));
-- Índice para ubicaciones pendientes de conteo
CREATE INDEX idx_locations_next_inventory
ON inventory.locations(next_inventory_date)
WHERE cyclic_inventory_frequency > 0;
-- -----------------------------------------------------------------------------
-- Extensión: quants (Agregar campos de inventario)
-- -----------------------------------------------------------------------------
ALTER TABLE inventory.quants
ADD COLUMN IF NOT EXISTS inventory_quantity NUMERIC(18,4),
ADD COLUMN IF NOT EXISTS inventory_quantity_set BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS inventory_diff_quantity NUMERIC(18,4) GENERATED ALWAYS AS (
COALESCE(inventory_quantity, 0) - quantity
) STORED,
ADD COLUMN IF NOT EXISTS inventory_date DATE,
ADD COLUMN IF NOT EXISTS last_count_date DATE,
ADD COLUMN IF NOT EXISTS is_outdated BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS assigned_user_id UUID REFERENCES core.users(id),
ADD COLUMN IF NOT EXISTS count_notes TEXT;
COMMENT ON COLUMN inventory.quants.inventory_quantity IS
'Cantidad contada por el usuario';
COMMENT ON COLUMN inventory.quants.inventory_diff_quantity IS
'Diferencia: contado - sistema. Positivo=faltante encontrado, Negativo=sobrante';
COMMENT ON COLUMN inventory.quants.is_outdated IS
'TRUE si quantity cambió después de establecer inventory_quantity';
-- Índice para quants con conteo pendiente
CREATE INDEX idx_quants_inventory_date
ON inventory.quants(inventory_date)
WHERE inventory_date IS NOT NULL;
-- Índice para quants asignados
CREATE INDEX idx_quants_assigned_user
ON inventory.quants(assigned_user_id)
WHERE assigned_user_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- Tabla: inventory_count_sessions (Sesiones de conteo)
-- -----------------------------------------------------------------------------
CREATE TABLE inventory.inventory_count_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(20) NOT NULL,
name VARCHAR(200),
-- Alcance del conteo
location_ids UUID[] NOT NULL, -- Ubicaciones a contar
product_ids UUID[], -- NULL = todos los productos
category_ids UUID[], -- Filtrar por categorías
-- Configuración
count_type VARCHAR(20) NOT NULL DEFAULT 'cycle',
-- 'cycle': Conteo cíclico programado
-- 'full': Inventario físico completo
-- 'spot': Conteo puntual/aleatorio
-- Estado
state VARCHAR(20) NOT NULL DEFAULT 'draft',
-- 'draft': En preparación
-- 'in_progress': Contando
-- 'pending_review': Esperando revisión de discrepancias
-- 'done': Completado
-- 'cancelled': Cancelado
-- Fechas
scheduled_date DATE,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Asignación
responsible_id UUID REFERENCES core.users(id),
team_ids UUID[], -- Usuarios asignados al conteo
-- Resultados
total_quants INTEGER DEFAULT 0,
counted_quants INTEGER DEFAULT 0,
discrepancy_quants INTEGER DEFAULT 0,
total_value_diff NUMERIC(18,2) DEFAULT 0,
-- Auditoría
company_id UUID NOT NULL REFERENCES core.companies(id),
warehouse_id UUID REFERENCES inventory.warehouses(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES core.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_count_type CHECK (count_type IN ('cycle', 'full', 'spot')),
CONSTRAINT chk_state CHECK (state IN ('draft', 'in_progress', 'pending_review', 'done', 'cancelled'))
);
-- Índices
CREATE INDEX idx_count_sessions_state ON inventory.inventory_count_sessions(state);
CREATE INDEX idx_count_sessions_scheduled ON inventory.inventory_count_sessions(scheduled_date);
-- Secuencia para código de sesión
CREATE SEQUENCE IF NOT EXISTS inventory.inventory_count_seq START 1;
-- -----------------------------------------------------------------------------
-- Tabla: inventory_count_lines (Líneas de conteo detalladas)
-- -----------------------------------------------------------------------------
CREATE TABLE inventory.inventory_count_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id UUID NOT NULL REFERENCES inventory.inventory_count_sessions(id) ON DELETE CASCADE,
quant_id UUID REFERENCES inventory.quants(id),
-- Producto
product_id UUID NOT NULL REFERENCES inventory.products(id),
location_id UUID NOT NULL REFERENCES inventory.locations(id),
lot_id UUID REFERENCES inventory.lots(id),
package_id UUID REFERENCES inventory.packages(id),
-- Cantidades
theoretical_qty NUMERIC(18,4) NOT NULL DEFAULT 0, -- Del sistema
counted_qty NUMERIC(18,4), -- Contada
difference_qty NUMERIC(18,4) GENERATED ALWAYS AS (
COALESCE(counted_qty, 0) - theoretical_qty
) STORED,
-- Valoración
unit_cost NUMERIC(18,6),
value_difference NUMERIC(18,2) GENERATED ALWAYS AS (
(COALESCE(counted_qty, 0) - theoretical_qty) * COALESCE(unit_cost, 0)
) STORED,
-- Estado
state VARCHAR(20) NOT NULL DEFAULT 'pending',
-- 'pending': No contado
-- 'counted': Contado
-- 'conflict': Requiere revisión
-- 'applied': Ajuste aplicado
-- Conteo
counted_by UUID REFERENCES core.users(id),
counted_at TIMESTAMPTZ,
notes TEXT,
-- Resolución de conflictos
conflict_reason VARCHAR(100),
resolution VARCHAR(20),
-- 'keep_counted': Mantener cantidad contada
-- 'keep_system': Mantener cantidad del sistema
-- 'recount': Recontar
resolved_by UUID REFERENCES core.users(id),
resolved_at TIMESTAMPTZ,
-- Movimiento generado
stock_move_id UUID REFERENCES inventory.stock_moves(id),
CONSTRAINT chk_line_state CHECK (state IN ('pending', 'counted', 'conflict', 'applied')),
CONSTRAINT chk_resolution CHECK (resolution IS NULL OR resolution IN ('keep_counted', 'keep_system', 'recount'))
);
-- Índices
CREATE INDEX idx_count_lines_session ON inventory.inventory_count_lines(session_id);
CREATE INDEX idx_count_lines_state ON inventory.inventory_count_lines(state);
CREATE INDEX idx_count_lines_product ON inventory.inventory_count_lines(product_id);
-- -----------------------------------------------------------------------------
-- Tabla: abc_classification_rules (Reglas de clasificación ABC)
-- -----------------------------------------------------------------------------
CREATE TABLE inventory.abc_classification_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
-- Criterio de clasificación
classification_method VARCHAR(20) NOT NULL DEFAULT 'value',
-- 'value': Por valor de inventario
-- 'movement': Por frecuencia de movimiento
-- 'revenue': Por ingresos generados
-- Umbrales (porcentaje acumulado)
threshold_a NUMERIC(5,2) NOT NULL DEFAULT 80.00, -- Top 80%
threshold_b NUMERIC(5,2) NOT NULL DEFAULT 95.00, -- 80-95%
-- Resto es C (95-100%)
-- Frecuencias de conteo recomendadas (días)
frequency_a INTEGER NOT NULL DEFAULT 7, -- Clase A: semanal
frequency_b INTEGER NOT NULL DEFAULT 30, -- Clase B: mensual
frequency_c INTEGER NOT NULL DEFAULT 90, -- Clase C: trimestral
-- Aplicación
warehouse_id UUID REFERENCES inventory.warehouses(id),
-- NULL = todas las bodegas
category_ids UUID[], -- Categorías a las que aplica
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_calculation TIMESTAMPTZ,
-- Auditoría
company_id UUID NOT NULL REFERENCES core.companies(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES core.users(id),
CONSTRAINT chk_thresholds CHECK (threshold_a < threshold_b AND threshold_b <= 100)
);
-- -----------------------------------------------------------------------------
-- Tabla: product_abc_classification (Clasificación ABC por producto)
-- -----------------------------------------------------------------------------
CREATE TABLE inventory.product_abc_classification (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES inventory.products(id),
rule_id UUID NOT NULL REFERENCES inventory.abc_classification_rules(id),
-- Clasificación
classification VARCHAR(1) NOT NULL,
-- Métricas calculadas
metric_value NUMERIC(18,2) NOT NULL, -- Valor usado para clasificar
cumulative_percent NUMERIC(5,2) NOT NULL, -- % acumulado
rank_position INTEGER NOT NULL, -- Posición en ranking
-- Período de cálculo
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Frecuencia asignada
assigned_frequency INTEGER NOT NULL,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_classification CHECK (classification IN ('A', 'B', 'C')),
CONSTRAINT uq_product_rule UNIQUE (product_id, rule_id)
);
-- Índice para búsqueda de clasificación
CREATE INDEX idx_product_abc ON inventory.product_abc_classification(product_id, rule_id);
-- -----------------------------------------------------------------------------
-- Extensión: stock_moves (Marcar movimientos de inventario)
-- -----------------------------------------------------------------------------
ALTER TABLE inventory.stock_moves
ADD COLUMN IF NOT EXISTS is_inventory BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS inventory_session_id UUID REFERENCES inventory.inventory_count_sessions(id);
CREATE INDEX idx_moves_is_inventory ON inventory.stock_moves(is_inventory)
WHERE is_inventory = TRUE;
2.3 Funciones de Soporte
-- Función para calcular clasificación ABC
CREATE OR REPLACE FUNCTION inventory.calculate_abc_classification(
p_rule_id UUID,
p_period_months INTEGER DEFAULT 12
)
RETURNS TABLE (
product_id UUID,
classification VARCHAR(1),
metric_value NUMERIC,
cumulative_percent NUMERIC,
rank_position INTEGER
) AS $$
DECLARE
v_rule RECORD;
v_total_value NUMERIC;
BEGIN
-- Obtener regla
SELECT * INTO v_rule
FROM inventory.abc_classification_rules
WHERE id = p_rule_id;
-- Calcular métrica según método
IF v_rule.classification_method = 'value' THEN
-- Por valor de inventario actual
CREATE TEMP TABLE tmp_metrics AS
SELECT
q.product_id,
SUM(q.quantity * p.standard_cost) as metric_value
FROM inventory.quants q
JOIN inventory.products p ON p.id = q.product_id
WHERE q.quantity > 0
AND (v_rule.warehouse_id IS NULL OR q.warehouse_id = v_rule.warehouse_id)
GROUP BY q.product_id;
ELSIF v_rule.classification_method = 'movement' THEN
-- Por frecuencia de movimientos
CREATE TEMP TABLE tmp_metrics AS
SELECT
sml.product_id,
COUNT(DISTINCT sm.id)::NUMERIC as metric_value
FROM inventory.stock_moves sm
JOIN inventory.stock_move_lines sml ON sml.move_id = sm.id
WHERE sm.state = 'done'
AND sm.date >= CURRENT_DATE - (p_period_months || ' months')::INTERVAL
AND (v_rule.warehouse_id IS NULL OR
EXISTS (SELECT 1 FROM inventory.locations l
WHERE l.id = sml.location_id AND l.warehouse_id = v_rule.warehouse_id))
GROUP BY sml.product_id;
ELSIF v_rule.classification_method = 'revenue' THEN
-- Por ingresos generados (requiere datos de ventas)
CREATE TEMP TABLE tmp_metrics AS
SELECT
sol.product_id,
SUM(sol.price_subtotal) as metric_value
FROM sales.order_lines sol
JOIN sales.orders so ON so.id = sol.order_id
WHERE so.state IN ('sale', 'done')
AND so.date_order >= CURRENT_DATE - (p_period_months || ' months')::INTERVAL
GROUP BY sol.product_id;
END IF;
-- Calcular total
SELECT COALESCE(SUM(metric_value), 0) INTO v_total_value FROM tmp_metrics;
-- Retornar clasificación
RETURN QUERY
WITH ranked AS (
SELECT
tm.product_id,
tm.metric_value,
ROW_NUMBER() OVER (ORDER BY tm.metric_value DESC) as rank_pos,
SUM(tm.metric_value) OVER (ORDER BY tm.metric_value DESC) /
NULLIF(v_total_value, 0) * 100 as cum_pct
FROM tmp_metrics tm
)
SELECT
r.product_id,
CASE
WHEN r.cum_pct <= v_rule.threshold_a THEN 'A'
WHEN r.cum_pct <= v_rule.threshold_b THEN 'B'
ELSE 'C'
END::VARCHAR(1) as classification,
r.metric_value,
ROUND(r.cum_pct, 2),
r.rank_pos::INTEGER
FROM ranked r;
DROP TABLE IF EXISTS tmp_metrics;
END;
$$ LANGUAGE plpgsql;
-- Función para marcar quants como desactualizados
CREATE OR REPLACE FUNCTION inventory.mark_quants_outdated()
RETURNS TRIGGER AS $$
BEGIN
-- Si cambió la cantidad de un quant con conteo pendiente
IF OLD.quantity != NEW.quantity AND OLD.inventory_quantity_set = TRUE THEN
NEW.is_outdated := TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_quant_outdated
BEFORE UPDATE OF quantity ON inventory.quants
FOR EACH ROW
EXECUTE FUNCTION inventory.mark_quants_outdated();
-- Función para obtener próximos conteos programados
CREATE OR REPLACE FUNCTION inventory.get_pending_counts(
p_days_ahead INTEGER DEFAULT 7
)
RETURNS TABLE (
location_id UUID,
location_name VARCHAR,
next_inventory_date DATE,
days_overdue INTEGER,
quant_count INTEGER,
total_value NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
l.id,
l.name,
l.next_inventory_date,
(CURRENT_DATE - l.next_inventory_date)::INTEGER as days_overdue,
COUNT(q.id)::INTEGER as quant_count,
COALESCE(SUM(q.quantity * p.standard_cost), 0) as total_value
FROM inventory.locations l
LEFT JOIN inventory.quants q ON q.location_id = l.id
LEFT JOIN inventory.products p ON p.id = q.product_id
WHERE l.cyclic_inventory_frequency > 0
AND l.next_inventory_date <= CURRENT_DATE + p_days_ahead
AND l.location_type = 'internal'
GROUP BY l.id, l.name, l.next_inventory_date
ORDER BY l.next_inventory_date;
END;
$$ LANGUAGE plpgsql;
3. Servicio de Conteo Cíclico
3.1 Servicio Principal
# services/cycle_count_service.py
from typing import Optional, List, Dict, Any
from uuid import UUID
from datetime import date, datetime, timedelta
from decimal import Decimal
from dataclasses import dataclass
from enum import Enum
class CountSessionState(str, Enum):
DRAFT = "draft"
IN_PROGRESS = "in_progress"
PENDING_REVIEW = "pending_review"
DONE = "done"
CANCELLED = "cancelled"
class CountLineState(str, Enum):
PENDING = "pending"
COUNTED = "counted"
CONFLICT = "conflict"
APPLIED = "applied"
class CountType(str, Enum):
CYCLE = "cycle" # Conteo cíclico programado
FULL = "full" # Inventario físico completo
SPOT = "spot" # Conteo puntual aleatorio
class ConflictResolution(str, Enum):
KEEP_COUNTED = "keep_counted"
KEEP_SYSTEM = "keep_system"
RECOUNT = "recount"
@dataclass
class CountDiscrepancy:
"""Representa una discrepancia encontrada."""
line_id: UUID
product_id: UUID
product_name: str
location_name: str
theoretical_qty: Decimal
counted_qty: Decimal
difference_qty: Decimal
value_difference: Decimal
conflict_reason: Optional[str]
class CycleCountService:
"""Servicio para gestión de conteos cíclicos de inventario."""
def __init__(
self,
db_session,
stock_move_service,
valuation_service,
notification_service
):
self.db = db_session
self.move_service = stock_move_service
self.valuation_service = valuation_service
self.notification_service = notification_service
# =========================================================================
# Gestión de Sesiones de Conteo
# =========================================================================
async def create_count_session(
self,
count_type: CountType,
location_ids: List[UUID],
user_id: UUID,
scheduled_date: Optional[date] = None,
product_ids: Optional[List[UUID]] = None,
category_ids: Optional[List[UUID]] = None,
warehouse_id: Optional[UUID] = None,
name: Optional[str] = None
) -> 'CountSession':
"""
Crea una nueva sesión de conteo de inventario.
Args:
count_type: Tipo de conteo (cycle, full, spot)
location_ids: Ubicaciones a contar
user_id: Usuario que crea la sesión
scheduled_date: Fecha programada (default: hoy)
product_ids: Productos específicos (None = todos)
category_ids: Categorías específicas (None = todas)
warehouse_id: Bodega (opcional)
name: Nombre descriptivo
Returns:
CountSession creada
"""
# Generar código
code = await self._generate_session_code()
# Nombre por defecto
if not name:
type_names = {
CountType.CYCLE: "Conteo Cíclico",
CountType.FULL: "Inventario Físico",
CountType.SPOT: "Conteo Puntual"
}
name = f"{type_names[count_type]} - {datetime.now().strftime('%Y-%m-%d')}"
# Crear sesión
session_data = {
'code': code,
'name': name,
'count_type': count_type.value,
'location_ids': location_ids,
'product_ids': product_ids,
'category_ids': category_ids,
'scheduled_date': scheduled_date or date.today(),
'warehouse_id': warehouse_id,
'responsible_id': user_id,
'state': CountSessionState.DRAFT.value,
'created_by': user_id
}
session = await self.db.execute(
"""
INSERT INTO inventory.inventory_count_sessions
(code, name, count_type, location_ids, product_ids, category_ids,
scheduled_date, warehouse_id, responsible_id, state, company_id, created_by)
VALUES (:code, :name, :count_type, :location_ids, :product_ids, :category_ids,
:scheduled_date, :warehouse_id, :responsible_id, :state,
(SELECT company_id FROM core.users WHERE id = :created_by), :created_by)
RETURNING *
""",
session_data
)
return session
async def start_count_session(
self,
session_id: UUID,
user_id: UUID
) -> 'CountSession':
"""
Inicia una sesión de conteo, generando las líneas a contar.
Acciones:
1. Valida estado (debe ser draft)
2. Genera líneas desde quants existentes
3. Asigna usuarios si hay equipo definido
4. Cambia estado a in_progress
"""
session = await self._get_session(session_id)
if session.state != CountSessionState.DRAFT.value:
raise InvalidStateError(
f"No se puede iniciar sesión en estado '{session.state}'"
)
# Generar líneas de conteo desde quants
lines = await self._generate_count_lines(session)
# Actualizar estadísticas de sesión
await self.db.execute(
"""
UPDATE inventory.inventory_count_sessions
SET state = 'in_progress',
started_at = CURRENT_TIMESTAMP,
total_quants = :total,
updated_at = CURRENT_TIMESTAMP
WHERE id = :session_id
""",
{'session_id': session_id, 'total': len(lines)}
)
# Notificar a usuarios asignados
if session.team_ids:
await self.notification_service.notify_count_started(
session_id=session_id,
user_ids=session.team_ids
)
return await self._get_session(session_id)
async def _generate_count_lines(
self,
session: 'CountSession'
) -> List['CountLine']:
"""Genera líneas de conteo desde quants."""
# Construir filtros
filters = {
'location_ids': session.location_ids
}
query = """
INSERT INTO inventory.inventory_count_lines
(session_id, quant_id, product_id, location_id, lot_id, package_id,
theoretical_qty, unit_cost, state)
SELECT
:session_id,
q.id,
q.product_id,
q.location_id,
q.lot_id,
q.package_id,
q.quantity,
p.standard_cost,
'pending'
FROM inventory.quants q
JOIN inventory.products p ON p.id = q.product_id
JOIN inventory.locations l ON l.id = q.location_id
WHERE l.id = ANY(:location_ids)
AND l.location_type = 'internal'
"""
params = {'session_id': session.id, 'location_ids': session.location_ids}
# Filtrar por productos si se especificaron
if session.product_ids:
query += " AND q.product_id = ANY(:product_ids)"
params['product_ids'] = session.product_ids
# Filtrar por categorías
if session.category_ids:
query += """
AND p.category_id IN (
SELECT id FROM inventory.categories
WHERE id = ANY(:category_ids)
OR parent_path LIKE ANY(
SELECT parent_path || '%' FROM inventory.categories
WHERE id = ANY(:category_ids)
)
)
"""
params['category_ids'] = session.category_ids
query += " RETURNING *"
result = await self.db.execute(query, params)
return result.fetchall()
# =========================================================================
# Registro de Conteos
# =========================================================================
async def record_count(
self,
line_id: UUID,
counted_qty: Decimal,
user_id: UUID,
notes: Optional[str] = None
) -> 'CountLine':
"""
Registra el conteo de una línea.
Args:
line_id: ID de la línea de conteo
counted_qty: Cantidad contada físicamente
user_id: Usuario que realizó el conteo
notes: Notas u observaciones
Returns:
Línea actualizada
"""
line = await self._get_count_line(line_id)
if line.state == CountLineState.APPLIED.value:
raise InvalidStateError("Esta línea ya fue aplicada")
# Verificar si el quant cambió desde que se inició el conteo
current_qty = await self._get_current_quant_qty(line.quant_id)
conflict_reason = None
state = CountLineState.COUNTED.value
if current_qty != line.theoretical_qty:
# El inventario cambió durante el conteo
conflict_reason = f"Cantidad cambió de {line.theoretical_qty} a {current_qty}"
state = CountLineState.CONFLICT.value
# Actualizar línea
await self.db.execute(
"""
UPDATE inventory.inventory_count_lines
SET counted_qty = :counted_qty,
counted_by = :user_id,
counted_at = CURRENT_TIMESTAMP,
notes = :notes,
state = :state,
conflict_reason = :conflict_reason
WHERE id = :line_id
""",
{
'line_id': line_id,
'counted_qty': counted_qty,
'user_id': user_id,
'notes': notes,
'state': state,
'conflict_reason': conflict_reason
}
)
# Actualizar también el quant si está en modo inventario
if line.quant_id:
await self.db.execute(
"""
UPDATE inventory.quants
SET inventory_quantity = :counted_qty,
inventory_quantity_set = TRUE,
assigned_user_id = :user_id,
is_outdated = :is_outdated
WHERE id = :quant_id
""",
{
'quant_id': line.quant_id,
'counted_qty': counted_qty,
'user_id': user_id,
'is_outdated': conflict_reason is not None
}
)
# Actualizar contador de sesión
await self._update_session_stats(line.session_id)
return await self._get_count_line(line_id)
async def batch_record_counts(
self,
counts: List[Dict[str, Any]],
user_id: UUID
) -> Dict[str, Any]:
"""
Registra múltiples conteos en lote.
Args:
counts: Lista de {line_id, counted_qty, notes}
user_id: Usuario que realizó los conteos
Returns:
Resumen de resultados
"""
results = {
'success': 0,
'conflicts': 0,
'errors': []
}
for count in counts:
try:
line = await self.record_count(
line_id=count['line_id'],
counted_qty=Decimal(str(count['counted_qty'])),
user_id=user_id,
notes=count.get('notes')
)
if line.state == CountLineState.CONFLICT.value:
results['conflicts'] += 1
else:
results['success'] += 1
except Exception as e:
results['errors'].append({
'line_id': count['line_id'],
'error': str(e)
})
return results
# =========================================================================
# Resolución de Conflictos
# =========================================================================
async def get_conflicts(
self,
session_id: UUID
) -> List[CountDiscrepancy]:
"""Obtiene todas las discrepancias de una sesión."""
result = await self.db.execute(
"""
SELECT
cl.id as line_id,
cl.product_id,
p.name as product_name,
l.name as location_name,
cl.theoretical_qty,
cl.counted_qty,
cl.difference_qty,
cl.value_difference,
cl.conflict_reason
FROM inventory.inventory_count_lines cl
JOIN inventory.products p ON p.id = cl.product_id
JOIN inventory.locations l ON l.id = cl.location_id
WHERE cl.session_id = :session_id
AND (cl.state = 'conflict' OR cl.difference_qty != 0)
ORDER BY ABS(cl.value_difference) DESC
""",
{'session_id': session_id}
)
return [
CountDiscrepancy(
line_id=row.line_id,
product_id=row.product_id,
product_name=row.product_name,
location_name=row.location_name,
theoretical_qty=row.theoretical_qty,
counted_qty=row.counted_qty,
difference_qty=row.difference_qty,
value_difference=row.value_difference,
conflict_reason=row.conflict_reason
)
for row in result
]
async def resolve_conflict(
self,
line_id: UUID,
resolution: ConflictResolution,
user_id: UUID,
notes: Optional[str] = None
) -> 'CountLine':
"""
Resuelve un conflicto de conteo.
Args:
line_id: ID de la línea en conflicto
resolution: Tipo de resolución
user_id: Usuario que resuelve
notes: Notas de la resolución
Returns:
Línea actualizada
"""
line = await self._get_count_line(line_id)
if line.state != CountLineState.CONFLICT.value:
raise InvalidStateError("Esta línea no está en conflicto")
new_state = CountLineState.COUNTED.value
if resolution == ConflictResolution.KEEP_COUNTED:
# Mantener lo que se contó
pass # counted_qty ya está establecida
elif resolution == ConflictResolution.KEEP_SYSTEM:
# Usar cantidad actual del sistema
current_qty = await self._get_current_quant_qty(line.quant_id)
await self.db.execute(
"UPDATE inventory.inventory_count_lines SET counted_qty = :qty WHERE id = :id",
{'id': line_id, 'qty': current_qty}
)
elif resolution == ConflictResolution.RECOUNT:
# Volver a estado pendiente para recontar
new_state = CountLineState.PENDING.value
await self.db.execute(
"""
UPDATE inventory.inventory_count_lines
SET counted_qty = NULL, counted_by = NULL, counted_at = NULL
WHERE id = :id
""",
{'id': line_id}
)
# Actualizar resolución
await self.db.execute(
"""
UPDATE inventory.inventory_count_lines
SET state = :state,
resolution = :resolution,
resolved_by = :user_id,
resolved_at = CURRENT_TIMESTAMP,
notes = COALESCE(notes || E'\n', '') || :notes
WHERE id = :line_id
""",
{
'line_id': line_id,
'state': new_state,
'resolution': resolution.value,
'user_id': user_id,
'notes': notes or ''
}
)
return await self._get_count_line(line_id)
# =========================================================================
# Aplicación de Ajustes
# =========================================================================
async def apply_adjustments(
self,
session_id: UUID,
user_id: UUID,
adjustment_reason: Optional[str] = None
) -> Dict[str, Any]:
"""
Aplica todos los ajustes de inventario de una sesión.
Proceso:
1. Validar que no hay conflictos pendientes
2. Crear movimientos de stock para cada diferencia
3. Generar capas de valoración
4. Actualizar quants
5. Cerrar sesión
Returns:
Resumen de ajustes aplicados
"""
session = await self._get_session(session_id)
# Verificar estado
if session.state not in [CountSessionState.IN_PROGRESS.value,
CountSessionState.PENDING_REVIEW.value]:
raise InvalidStateError(
f"No se pueden aplicar ajustes en estado '{session.state}'"
)
# Verificar que no hay conflictos sin resolver
conflicts = await self.db.execute(
"""
SELECT COUNT(*) FROM inventory.inventory_count_lines
WHERE session_id = :session_id AND state = 'conflict'
""",
{'session_id': session_id}
)
if conflicts.count > 0:
raise ValidationError(
f"Hay {conflicts.count} conflictos pendientes de resolver"
)
# Obtener líneas con diferencias
lines_with_diff = await self.db.execute(
"""
SELECT cl.*, p.name as product_name,
p.property_stock_inventory as inventory_loss_location_id
FROM inventory.inventory_count_lines cl
JOIN inventory.products p ON p.id = cl.product_id
WHERE cl.session_id = :session_id
AND cl.state = 'counted'
AND cl.difference_qty != 0
""",
{'session_id': session_id}
)
results = {
'adjustments_applied': 0,
'total_quantity_adjusted': Decimal('0'),
'total_value_impact': Decimal('0'),
'moves_created': []
}
# Crear movimientos para cada ajuste
for line in lines_with_diff:
move = await self._create_adjustment_move(
line=line,
user_id=user_id,
reason=adjustment_reason or f"Ajuste de inventario {session.code}"
)
# Actualizar línea
await self.db.execute(
"""
UPDATE inventory.inventory_count_lines
SET state = 'applied', stock_move_id = :move_id
WHERE id = :line_id
""",
{'line_id': line.id, 'move_id': move.id}
)
# Actualizar quant
await self._clear_quant_inventory_fields(line.quant_id)
results['adjustments_applied'] += 1
results['total_quantity_adjusted'] += abs(line.difference_qty)
results['total_value_impact'] += abs(line.value_difference)
results['moves_created'].append(move.id)
# Actualizar fecha de último inventario en ubicaciones
await self.db.execute(
"""
UPDATE inventory.locations
SET last_inventory_date = CURRENT_DATE
WHERE id = ANY(:location_ids)
""",
{'location_ids': session.location_ids}
)
# Cerrar sesión
await self.db.execute(
"""
UPDATE inventory.inventory_count_sessions
SET state = 'done',
completed_at = CURRENT_TIMESTAMP,
discrepancy_quants = :discrepancies,
total_value_diff = :value_diff,
updated_at = CURRENT_TIMESTAMP
WHERE id = :session_id
""",
{
'session_id': session_id,
'discrepancies': results['adjustments_applied'],
'value_diff': results['total_value_impact']
}
)
return results
async def _create_adjustment_move(
self,
line: 'CountLine',
user_id: UUID,
reason: str
) -> 'StockMove':
"""Crea un movimiento de stock para el ajuste."""
# Determinar origen y destino según si es positivo o negativo
if line.difference_qty > 0:
# Diferencia positiva: faltante encontrado, agregar stock
# inventory_loss → location
source_location_id = line.inventory_loss_location_id
dest_location_id = line.location_id
quantity = line.difference_qty
else:
# Diferencia negativa: sobrante encontrado, quitar stock
# location → inventory_loss
source_location_id = line.location_id
dest_location_id = line.inventory_loss_location_id
quantity = abs(line.difference_qty)
move_data = {
'name': reason,
'product_id': line.product_id,
'product_uom_id': await self._get_product_uom(line.product_id),
'product_qty': quantity,
'location_id': source_location_id,
'location_dest_id': dest_location_id,
'lot_id': line.lot_id,
'package_id': line.package_id,
'is_inventory': True,
'inventory_session_id': line.session_id,
'state': 'draft',
'created_by': user_id
}
# Crear y validar movimiento
move = await self.move_service.create(move_data)
await self.move_service.action_done(move.id, user_id)
return move
# =========================================================================
# Clasificación ABC
# =========================================================================
async def calculate_abc_classification(
self,
rule_id: UUID,
user_id: UUID
) -> Dict[str, Any]:
"""
Calcula y aplica clasificación ABC según una regla.
Returns:
Resumen de clasificación
"""
rule = await self._get_abc_rule(rule_id)
# Ejecutar función de clasificación
results = await self.db.execute(
"SELECT * FROM inventory.calculate_abc_classification(:rule_id, 12)",
{'rule_id': rule_id}
)
# Guardar resultados
count_a = count_b = count_c = 0
for row in results:
await self.db.execute(
"""
INSERT INTO inventory.product_abc_classification
(product_id, rule_id, classification, metric_value,
cumulative_percent, rank_position, assigned_frequency,
period_start, period_end)
VALUES (:product_id, :rule_id, :classification, :metric_value,
:cum_pct, :rank, :frequency,
CURRENT_DATE - INTERVAL '12 months', CURRENT_DATE)
ON CONFLICT (product_id, rule_id) DO UPDATE SET
classification = EXCLUDED.classification,
metric_value = EXCLUDED.metric_value,
cumulative_percent = EXCLUDED.cumulative_percent,
rank_position = EXCLUDED.rank_position,
assigned_frequency = EXCLUDED.assigned_frequency,
period_end = EXCLUDED.period_end,
calculated_at = CURRENT_TIMESTAMP
""",
{
'product_id': row.product_id,
'rule_id': rule_id,
'classification': row.classification,
'metric_value': row.metric_value,
'cum_pct': row.cumulative_percent,
'rank': row.rank_position,
'frequency': (
rule.frequency_a if row.classification == 'A'
else rule.frequency_b if row.classification == 'B'
else rule.frequency_c
)
}
)
if row.classification == 'A':
count_a += 1
elif row.classification == 'B':
count_b += 1
else:
count_c += 1
# Actualizar regla
await self.db.execute(
"""
UPDATE inventory.abc_classification_rules
SET last_calculation = CURRENT_TIMESTAMP
WHERE id = :rule_id
""",
{'rule_id': rule_id}
)
return {
'rule_id': rule_id,
'total_products': count_a + count_b + count_c,
'class_a': count_a,
'class_b': count_b,
'class_c': count_c,
'calculated_at': datetime.now()
}
# =========================================================================
# Programación Automática
# =========================================================================
async def get_scheduled_counts(
self,
days_ahead: int = 7,
warehouse_id: Optional[UUID] = None
) -> List[Dict[str, Any]]:
"""
Obtiene ubicaciones con conteos programados próximos.
Args:
days_ahead: Días hacia adelante a considerar
warehouse_id: Filtrar por bodega
Returns:
Lista de ubicaciones pendientes de conteo
"""
query = """
SELECT * FROM inventory.get_pending_counts(:days_ahead)
"""
params = {'days_ahead': days_ahead}
if warehouse_id:
query = query.replace(
"FROM inventory.get_pending_counts",
"""
FROM inventory.get_pending_counts(:days_ahead)
WHERE location_id IN (
SELECT id FROM inventory.locations WHERE warehouse_id = :warehouse_id
)
"""
)
params['warehouse_id'] = warehouse_id
return await self.db.execute(query, params)
async def auto_create_scheduled_sessions(
self,
user_id: UUID
) -> List['CountSession']:
"""
Crea sesiones de conteo automáticamente para ubicaciones vencidas.
Se ejecuta típicamente como job programado.
"""
# Obtener ubicaciones con conteo vencido
overdue = await self.get_scheduled_counts(days_ahead=0)
sessions = []
# Agrupar por bodega
warehouse_locations = {}
for loc in overdue:
wh_id = loc.get('warehouse_id')
if wh_id not in warehouse_locations:
warehouse_locations[wh_id] = []
warehouse_locations[wh_id].append(loc['location_id'])
# Crear sesión por bodega
for warehouse_id, location_ids in warehouse_locations.items():
session = await self.create_count_session(
count_type=CountType.CYCLE,
location_ids=location_ids,
user_id=user_id,
warehouse_id=warehouse_id,
name=f"Conteo Cíclico Automático - {date.today()}"
)
sessions.append(session)
return sessions
4. API REST
4.1 Endpoints
# Sesiones de Conteo
POST /api/v1/inventory/count-sessions # Crear sesión
GET /api/v1/inventory/count-sessions # Listar sesiones
GET /api/v1/inventory/count-sessions/{id} # Detalle de sesión
POST /api/v1/inventory/count-sessions/{id}/start # Iniciar conteo
POST /api/v1/inventory/count-sessions/{id}/apply # Aplicar ajustes
POST /api/v1/inventory/count-sessions/{id}/cancel # Cancelar sesión
# Líneas de Conteo
GET /api/v1/inventory/count-sessions/{id}/lines # Listar líneas
POST /api/v1/inventory/count-sessions/{id}/lines/count # Registrar conteo
POST /api/v1/inventory/count-sessions/{id}/lines/batch # Conteo en lote
# Conflictos
GET /api/v1/inventory/count-sessions/{id}/conflicts # Ver conflictos
POST /api/v1/inventory/count-lines/{id}/resolve # Resolver conflicto
# Clasificación ABC
GET /api/v1/inventory/abc-rules # Listar reglas
POST /api/v1/inventory/abc-rules # Crear regla
POST /api/v1/inventory/abc-rules/{id}/calculate # Ejecutar clasificación
GET /api/v1/inventory/products/{id}/abc # Ver clasificación de producto
# Programación
GET /api/v1/inventory/scheduled-counts # Conteos programados
POST /api/v1/inventory/scheduled-counts/auto-create # Crear sesiones automáticas
# Reportes
GET /api/v1/inventory/count-sessions/{id}/report # Reporte de sesión
GET /api/v1/inventory/adjustment-history # Historial de ajustes
4.2 Schemas
# schemas/cycle_count_schemas.py
from pydantic import BaseModel, Field
from typing import Optional, List
from uuid import UUID
from decimal import Decimal
from datetime import date, datetime
from enum import Enum
class CountType(str, Enum):
CYCLE = "cycle"
FULL = "full"
SPOT = "spot"
class CountSessionState(str, Enum):
DRAFT = "draft"
IN_PROGRESS = "in_progress"
PENDING_REVIEW = "pending_review"
DONE = "done"
CANCELLED = "cancelled"
class ConflictResolution(str, Enum):
KEEP_COUNTED = "keep_counted"
KEEP_SYSTEM = "keep_system"
RECOUNT = "recount"
# ================== SESIONES ==================
class CountSessionCreate(BaseModel):
count_type: CountType
location_ids: List[UUID]
scheduled_date: Optional[date] = None
product_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
warehouse_id: Optional[UUID] = None
name: Optional[str] = None
team_ids: Optional[List[UUID]] = None
class CountSessionResponse(BaseModel):
id: UUID
code: str
name: str
count_type: CountType
state: CountSessionState
scheduled_date: Optional[date]
started_at: Optional[datetime]
completed_at: Optional[datetime]
responsible_id: UUID
responsible_name: str
warehouse_id: Optional[UUID]
warehouse_name: Optional[str]
location_count: int
total_quants: int
counted_quants: int
discrepancy_quants: int
total_value_diff: Decimal
progress_percent: float
class Config:
from_attributes = True
# ================== LÍNEAS ==================
class CountLineResponse(BaseModel):
id: UUID
product_id: UUID
product_name: str
product_code: str
location_id: UUID
location_name: str
lot_id: Optional[UUID]
lot_name: Optional[str]
theoretical_qty: Decimal
counted_qty: Optional[Decimal]
difference_qty: Decimal
unit_cost: Decimal
value_difference: Decimal
state: str
conflict_reason: Optional[str]
counted_by: Optional[UUID]
counted_at: Optional[datetime]
notes: Optional[str]
class RecordCountRequest(BaseModel):
line_id: UUID
counted_qty: Decimal = Field(ge=0)
notes: Optional[str] = None
class BatchCountRequest(BaseModel):
counts: List[RecordCountRequest]
class ResolveConflictRequest(BaseModel):
resolution: ConflictResolution
notes: Optional[str] = None
# ================== ABC ==================
class ABCRuleCreate(BaseModel):
name: str
classification_method: str = "value"
threshold_a: Decimal = Decimal("80.00")
threshold_b: Decimal = Decimal("95.00")
frequency_a: int = 7
frequency_b: int = 30
frequency_c: int = 90
warehouse_id: Optional[UUID] = None
category_ids: Optional[List[UUID]] = None
class ABCClassificationResponse(BaseModel):
product_id: UUID
product_name: str
classification: str
metric_value: Decimal
cumulative_percent: Decimal
assigned_frequency: int
last_count_date: Optional[date]
next_count_date: Optional[date]
# ================== REPORTES ==================
class CountSessionReport(BaseModel):
session: CountSessionResponse
summary: dict
discrepancies: List[dict]
by_location: List[dict]
by_category: List[dict]
value_impact: dict
class ScheduledCountResponse(BaseModel):
location_id: UUID
location_name: str
warehouse_name: str
next_inventory_date: date
days_overdue: int
quant_count: int
total_value: Decimal
abc_classification: Optional[str]
5. Flujos de Trabajo
5.1 Flujo de Conteo Cíclico
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUJO DE CONTEO CÍCLICO │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. PROGRAMACIÓN │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Job Automático (diario) │ │
│ │ └─► Identificar ubicaciones con next_inventory_date <= hoy │ │
│ │ └─► Crear sesiones de conteo agrupadas por bodega │ │
│ │ └─► Notificar a responsables │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. PREPARACIÓN │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Responsable revisa sesión │ │
│ │ └─► Asigna equipo de conteo │ │
│ │ └─► Ajusta alcance si necesario │ │
│ │ └─► Click "Iniciar Conteo" │ │
│ │ │ │
│ │ Sistema genera líneas de conteo desde quants │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. CONTEO FÍSICO │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Operadores cuentan productos │ │
│ │ └─► Escanean ubicación │ │
│ │ └─► Escanean producto/lote │ │
│ │ └─► Ingresan cantidad contada │ │
│ │ └─► Agregan notas si hay observaciones │ │
│ │ │ │
│ │ Opciones: │ │
│ │ • Conteo individual por línea │ │
│ │ • Conteo en lote (import Excel) │ │
│ │ • App móvil con escáner │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 4. DETECCIÓN DE CONFLICTOS │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Sistema detecta automáticamente: │ │
│ │ └─► Cantidad cambió durante conteo (is_outdated) │ │
│ │ └─► Producto trackeado sin lote asignado │ │
│ │ └─► Discrepancias significativas │ │
│ │ │ │
│ │ Marca líneas como "conflict" para revisión │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 5. REVISIÓN Y RESOLUCIÓN │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Supervisor revisa discrepancias │ │
│ │ │ │
│ │ Para cada conflicto: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Opción 1: KEEP_COUNTED │ │ │
│ │ │ └─► Confiar en el conteo físico │ │ │
│ │ ├─────────────────────────────────────────────────────────┤ │ │
│ │ │ Opción 2: KEEP_SYSTEM │ │ │
│ │ │ └─► Mantener cantidad del sistema │ │ │
│ │ ├─────────────────────────────────────────────────────────┤ │ │
│ │ │ Opción 3: RECOUNT │ │ │
│ │ │ └─► Volver a contar el producto │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 6. APLICACIÓN DE AJUSTES │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Click "Aplicar Ajustes" │ │
│ │ │ │
│ │ Para cada diferencia != 0: │ │
│ │ └─► Crear stock.move (is_inventory=True) │ │
│ │ • Si faltante: inventory_loss → location │ │
│ │ • Si sobrante: location → inventory_loss │ │
│ │ └─► Generar valuation_layer (impacto en costo) │ │
│ │ └─► Crear asiento contable (si real-time) │ │
│ │ └─► Actualizar quant.quantity │ │
│ │ │ │
│ │ Actualizar ubicaciones: │ │
│ │ └─► last_inventory_date = hoy │ │
│ │ └─► Recalcular next_inventory_date │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 7. CIERRE │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Sesión cambia a estado "done" │ │
│ │ └─► Registrar estadísticas finales │ │
│ │ └─► Generar reporte de ajustes │ │
│ │ └─► Notificar a stakeholders │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.2 Integración con Valoración
┌─────────────────────────────────────────────────────────────────────────────┐
│ IMPACTO EN VALORACIÓN DE INVENTARIO │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Ajuste de Inventario (difference_qty = +5) │
│ ────────────────────────────────────────── │
│ │
│ 1. MOVIMIENTO DE STOCK │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ stock_move: │ │
│ │ is_inventory: TRUE │ │
│ │ location_id: Inventory Loss (virtual) │ │
│ │ location_dest_id: WH/Stock (internal) │ │
│ │ product_qty: 5 │ │
│ │ state: done │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. CAPA DE VALORACIÓN │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ stock_valuation_layer: │ │
│ │ product_id: [Producto] │ │
│ │ quantity: +5 │ │
│ │ unit_cost: $10.00 (costo estándar o FIFO) │ │
│ │ value: +$50.00 │ │
│ │ stock_move_id: [Movimiento arriba] │ │
│ │ description: "Ajuste de inventario CI-00001" │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. ASIENTO CONTABLE (Si valoración en tiempo real) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ account_move: │ │
│ │ journal: Inventory Valuation │ │
│ │ date: [Fecha de ajuste] │ │
│ │ │ │
│ │ line 1: │ │
│ │ account: Stock Valuation Account │ │
│ │ debit: $50.00 │ │
│ │ │ │
│ │ line 2: │ │
│ │ account: Inventory Adjustment Account │ │
│ │ credit: $50.00 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ═══════════════════════════════════════════════════════════════════════ │
│ │
│ Ajuste de Inventario (difference_qty = -3) │
│ ────────────────────────────────────────── │
│ │
│ 1. MOVIMIENTO DE STOCK │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ stock_move: │ │
│ │ is_inventory: TRUE │ │
│ │ location_id: WH/Stock (internal) │ │
│ │ location_dest_id: Inventory Loss (virtual) │ │
│ │ product_qty: 3 │ │
│ │ state: done │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. CAPA DE VALORACIÓN │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ stock_valuation_layer: │ │
│ │ quantity: -3 │ │
│ │ unit_cost: $10.00 │ │
│ │ value: -$30.00 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. ASIENTO CONTABLE │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ line 1: │ │
│ │ account: Inventory Adjustment Account │ │
│ │ debit: $30.00 │ │
│ │ │ │
│ │ line 2: │ │
│ │ account: Stock Valuation Account │ │
│ │ credit: $30.00 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Jobs Programados
6.1 Creación Automática de Sesiones
# jobs/cycle_count_scheduler.py
class CycleCountSchedulerJob:
"""Job diario para crear sesiones de conteo cíclico."""
schedule = "0 6 * * *" # 6:00 AM diario
async def run(self):
"""
Proceso:
1. Identificar ubicaciones vencidas
2. Agrupar por bodega
3. Crear sesiones
4. Notificar responsables
"""
service = CycleCountService(...)
# Crear sesiones automáticas
sessions = await service.auto_create_scheduled_sessions(
user_id=SYSTEM_USER_ID
)
# Notificar
for session in sessions:
await self.notification_service.send(
template='cycle_count_scheduled',
recipients=[session.responsible_id],
data={
'session_code': session.code,
'location_count': len(session.location_ids),
'scheduled_date': session.scheduled_date
}
)
return {
'sessions_created': len(sessions),
'timestamp': datetime.now()
}
6.2 Recálculo ABC Periódico
# jobs/abc_recalculation_job.py
class ABCRecalculationJob:
"""Job mensual para recalcular clasificación ABC."""
schedule = "0 2 1 * *" # 2:00 AM, día 1 de cada mes
async def run(self):
"""Recalcula ABC para todas las reglas activas."""
service = CycleCountService(...)
rules = await self.db.execute(
"SELECT id FROM inventory.abc_classification_rules WHERE is_active = TRUE"
)
results = []
for rule in rules:
result = await service.calculate_abc_classification(
rule_id=rule.id,
user_id=SYSTEM_USER_ID
)
results.append(result)
return {
'rules_processed': len(results),
'results': results
}
7. Consideraciones de Implementación
7.1 Permisos
CYCLE_COUNT_PERMISSIONS = {
'inventory.count_session.create': ['inventory_manager', 'warehouse_manager'],
'inventory.count_session.start': ['inventory_manager', 'warehouse_manager'],
'inventory.count_session.view': ['inventory_user', 'inventory_manager'],
'inventory.count.record': ['inventory_user', 'inventory_manager'],
'inventory.conflict.resolve': ['inventory_manager'],
'inventory.adjustment.apply': ['inventory_manager', 'accounting_manager'],
'inventory.abc.manage': ['inventory_manager'],
}
7.2 Configuración
CYCLE_COUNT_CONFIG = {
# Umbrales de alerta
'warning_threshold_percent': 5.0, # Discrepancia > 5% genera alerta
'critical_threshold_value': 1000.0, # Discrepancia > $1000 requiere aprobación
# Programación
'auto_create_sessions': True,
'default_count_frequency_days': 30,
# ABC
'abc_recalc_frequency_months': 1,
'default_frequency_a': 7,
'default_frequency_b': 30,
'default_frequency_c': 90,
# Comportamiento
'allow_negative_inventory': False,
'require_lot_for_tracked': True,
'auto_resolve_zero_diff': True,
}
8. Referencias
8.1 Odoo
addons/stock/models/stock_quant.py(inventory mode)addons/stock/models/stock_location.py(cyclic frequency)addons/stock_account/models/stock_valuation_layer.py
8.2 Estándares
- ABC Analysis (Pareto Principle)
- Cycle Counting Best Practices (APICS)
8.3 Documentos Relacionados
- SPEC-VALORACION-INVENTARIO
- SPEC-TRAZABILIDAD-LOTES-SERIES
Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0.0 | 2025-01-15 | AI Assistant | Versión inicial |