# 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 ```sql -- ============================================================================= -- 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 ```sql -- 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 ```python # 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 ```yaml # 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 ```python # 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 ```python # 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 ```python # 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 ```python 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 ```python 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 |