erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-INVENTARIOS-CICLICOS.md

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

  1. Implementar modelo basado en quants con modo de inventario
  2. Configurar frecuencias de conteo por ubicación
  3. Soportar clasificación ABC para priorización de conteos
  4. Gestionar conflictos cuando el inventario cambia durante el conteo
  5. Integrar con valoración de inventario y contabilidad
  6. 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