erp-construccion/docs/02-definicion-modulos/MAI-004-compras-inventarios/especificaciones/ET-PURCH-004-implementacion-kardex-alertas.md

28 KiB

ET-PURCH-004: Implementación de Kárdex y Alertas

Épica: MAI-004 - Compras e Inventarios Versión: 1.0 Fecha: 2025-11-17


1. Schemas SQL

-- Tabla: material_stock_config
CREATE TABLE inventory.material_stock_config (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
  material_id UUID NOT NULL,

  minimum_stock DECIMAL(12,4) NOT NULL,
  maximum_stock DECIMAL(12,4),
  reorder_point DECIMAL(12,4) NOT NULL,
  lead_time_days INTEGER DEFAULT 7,

  alert_on_minimum BOOLEAN DEFAULT true,
  alert_on_reorder BOOLEAN DEFAULT true,
  alert_on_overconsumption BOOLEAN DEFAULT true,
  alert_on_no_movement BOOLEAN DEFAULT false,
  no_movement_days INTEGER DEFAULT 90,

  notify_users UUID[] DEFAULT '{}',

  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT unique_warehouse_material_config UNIQUE (warehouse_id, material_id),
  CONSTRAINT check_stock_levels CHECK (
    minimum_stock < reorder_point AND
    reorder_point < maximum_stock
  )
);

CREATE INDEX idx_stock_config_warehouse ON inventory.material_stock_config(warehouse_id);
CREATE INDEX idx_stock_config_material ON inventory.material_stock_config(material_id);


-- Tabla: consumption_analysis
CREATE TABLE inventory.consumption_analysis (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL REFERENCES projects.projects(id),
  material_id UUID NOT NULL,

  analysis_period VARCHAR(7) NOT NULL, -- 2025-11

  budgeted_quantity DECIMAL(12,4) NOT NULL,
  actual_quantity DECIMAL(12,4) NOT NULL,
  variance DECIMAL(12,4) GENERATED ALWAYS AS (actual_quantity - budgeted_quantity) STORED,
  variance_percentage DECIMAL(6,2) GENERATED ALWAYS AS (
    CASE
      WHEN budgeted_quantity = 0 THEN 0
      ELSE ((actual_quantity - budgeted_quantity) / budgeted_quantity) * 100
    END
  ) STORED,

  budgeted_cost DECIMAL(15,2) NOT NULL,
  actual_cost DECIMAL(15,2) NOT NULL,
  cost_variance DECIMAL(15,2) GENERATED ALWAYS AS (actual_cost - budgeted_cost) STORED,

  average_weekly_consumption DECIMAL(12,4),
  projected_total DECIMAL(12,4),
  projected_cost DECIMAL(15,2),

  status VARCHAR(20) DEFAULT 'ok' CHECK (status IN ('ok', 'warning', 'critical')),
  analysis_date DATE DEFAULT CURRENT_DATE,

  notes TEXT,

  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT unique_project_material_period UNIQUE (project_id, material_id, analysis_period)
);

CREATE INDEX idx_consumption_project ON inventory.consumption_analysis(project_id);
CREATE INDEX idx_consumption_period ON inventory.consumption_analysis(analysis_period);
CREATE INDEX idx_consumption_status ON inventory.consumption_analysis(status);


-- Tabla: stock_alerts
CREATE TABLE inventory.stock_alerts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  alert_type VARCHAR(30) NOT NULL CHECK (alert_type IN ('minimum_stock', 'reorder_point', 'overconsumption', 'no_movement', 'maximum_stock')),
  severity VARCHAR(20) DEFAULT 'warning' CHECK (severity IN ('info', 'warning', 'critical')),

  warehouse_id UUID REFERENCES inventory.warehouses(id),
  material_id UUID NOT NULL,
  project_id UUID REFERENCES projects.projects(id),

  message TEXT NOT NULL,
  current_value DECIMAL(12,4),
  threshold_value DECIMAL(12,4),

  metadata JSONB,
  /* Estructura metadata:
  {
    averageConsumption: number,
    daysRemaining: number,
    suggestedOrderQuantity: number,
    recommendedSuppliers: UUID[]
  }
  */

  status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'acknowledged', 'resolved')),
  notified_users UUID[] DEFAULT '{}',

  acknowledged_by UUID REFERENCES auth.users(id),
  acknowledged_at TIMESTAMP,

  resolved_by UUID REFERENCES auth.users(id),
  resolved_at TIMESTAMP,

  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_alerts_type ON inventory.stock_alerts(alert_type);
CREATE INDEX idx_alerts_severity ON inventory.stock_alerts(severity);
CREATE INDEX idx_alerts_status ON inventory.stock_alerts(status);
CREATE INDEX idx_alerts_warehouse ON inventory.stock_alerts(warehouse_id);
CREATE INDEX idx_alerts_material ON inventory.stock_alerts(material_id);
CREATE INDEX idx_alerts_created ON inventory.stock_alerts(created_at DESC);


-- Tabla: inventory_rotation_analysis
CREATE TABLE inventory.inventory_rotation_analysis (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
  analysis_period VARCHAR(7) NOT NULL, -- 2025-11

  total_materials INTEGER,
  total_inventory_value DECIMAL(15,2),

  rotation_index DECIMAL(6,2), -- veces por año
  average_days_in_stock INTEGER,

  class_a_materials INTEGER, -- 80% del valor
  class_b_materials INTEGER, -- 15% del valor
  class_c_materials INTEGER, -- 5% del valor

  slow_moving_materials INTEGER,
  obsolete_materials INTEGER,

  analysis_date DATE DEFAULT CURRENT_DATE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT unique_warehouse_period UNIQUE (warehouse_id, analysis_period)
);

CREATE INDEX idx_rotation_warehouse ON inventory.inventory_rotation_analysis(warehouse_id);
CREATE INDEX idx_rotation_period ON inventory.inventory_rotation_analysis(analysis_period);


-- Función: Generar alerta de stock mínimo
CREATE OR REPLACE FUNCTION inventory.check_minimum_stock_alert()
RETURNS TRIGGER AS $$
DECLARE
  v_config RECORD;
  v_alert_exists BOOLEAN;
BEGIN
  -- Obtener configuración del material
  SELECT * INTO v_config
  FROM inventory.material_stock_config
  WHERE warehouse_id = NEW.warehouse_id
    AND material_id = NEW.material_id
    AND alert_on_minimum = true;

  IF NOT FOUND THEN
    RETURN NEW;
  END IF;

  -- Si está por debajo del stock mínimo
  IF NEW.quantity < v_config.minimum_stock THEN
    -- Verificar si ya existe una alerta activa
    SELECT EXISTS (
      SELECT 1 FROM inventory.stock_alerts
      WHERE warehouse_id = NEW.warehouse_id
        AND material_id = NEW.material_id
        AND alert_type = 'minimum_stock'
        AND status = 'active'
    ) INTO v_alert_exists;

    IF NOT v_alert_exists THEN
      INSERT INTO inventory.stock_alerts (
        alert_type,
        severity,
        warehouse_id,
        material_id,
        message,
        current_value,
        threshold_value,
        notified_users
      ) VALUES (
        'minimum_stock',
        'critical',
        NEW.warehouse_id,
        NEW.material_id,
        'Stock por debajo del mínimo configurado',
        NEW.quantity,
        v_config.minimum_stock,
        v_config.notify_users
      );
    END IF;
  ELSE
    -- Si el stock vuelve a niveles normales, resolver la alerta
    UPDATE inventory.stock_alerts
    SET status = 'resolved',
        resolved_at = CURRENT_TIMESTAMP
    WHERE warehouse_id = NEW.warehouse_id
      AND material_id = NEW.material_id
      AND alert_type = 'minimum_stock'
      AND status = 'active';
  END IF;

  -- Verificar punto de reorden
  IF NEW.quantity <= v_config.reorder_point AND v_config.alert_on_reorder THEN
    SELECT EXISTS (
      SELECT 1 FROM inventory.stock_alerts
      WHERE warehouse_id = NEW.warehouse_id
        AND material_id = NEW.material_id
        AND alert_type = 'reorder_point'
        AND status = 'active'
    ) INTO v_alert_exists;

    IF NOT v_alert_exists THEN
      INSERT INTO inventory.stock_alerts (
        alert_type,
        severity,
        warehouse_id,
        material_id,
        message,
        current_value,
        threshold_value,
        notified_users
      ) VALUES (
        'reorder_point',
        'warning',
        NEW.warehouse_id,
        NEW.material_id,
        'Punto de reorden alcanzado',
        NEW.quantity,
        v_config.reorder_point,
        v_config.notify_users
      );
    END IF;
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_check_stock_alerts
AFTER UPDATE OF quantity ON inventory.inventory_stock
FOR EACH ROW
WHEN (NEW.quantity IS DISTINCT FROM OLD.quantity)
EXECUTE FUNCTION inventory.check_minimum_stock_alert();


-- Función: Analizar consumo vs presupuesto
CREATE OR REPLACE FUNCTION inventory.analyze_consumption(
  p_project_id UUID,
  p_period VARCHAR(7)
)
RETURNS VOID AS $$
DECLARE
  v_material RECORD;
  v_budgeted DECIMAL(12,4);
  v_consumed DECIMAL(12,4);
  v_variance_pct DECIMAL(6,2);
  v_status VARCHAR(20);
BEGIN
  FOR v_material IN
    SELECT DISTINCT m.material_id
    FROM inventory.inventory_movements m
    WHERE m.project_id = p_project_id
      AND TO_CHAR(m.movement_date, 'YYYY-MM') = p_period
      AND m.movement_type = 'exit'
  LOOP
    -- Obtener cantidad presupuestada
    SELECT budgeted_quantity INTO v_budgeted
    FROM budgets.budget_items
    WHERE project_id = p_project_id
      AND material_id = v_material.material_id;

    -- Calcular cantidad consumida en el período
    SELECT COALESCE(SUM((item->>'quantity')::DECIMAL), 0)
    INTO v_consumed
    FROM inventory.inventory_movements m,
         JSONB_ARRAY_ELEMENTS(m.items) AS item
    WHERE m.project_id = p_project_id
      AND TO_CHAR(m.movement_date, 'YYYY-MM') = p_period
      AND m.movement_type = 'exit'
      AND (item->>'materialId')::UUID = v_material.material_id;

    -- Calcular varianza
    IF v_budgeted > 0 THEN
      v_variance_pct := ((v_consumed - v_budgeted) / v_budgeted) * 100;
    ELSE
      v_variance_pct := 0;
    END IF;

    -- Determinar status
    v_status := CASE
      WHEN v_variance_pct < -5 THEN 'ok'
      WHEN v_variance_pct BETWEEN -5 AND 5 THEN 'ok'
      WHEN v_variance_pct BETWEEN 5 AND 10 THEN 'warning'
      ELSE 'critical'
    END;

    -- Insertar análisis
    INSERT INTO inventory.consumption_analysis (
      project_id,
      material_id,
      analysis_period,
      budgeted_quantity,
      actual_quantity,
      budgeted_cost,
      actual_cost,
      status
    ) VALUES (
      p_project_id,
      v_material.material_id,
      p_period,
      v_budgeted,
      v_consumed,
      0, -- calcular desde presupuesto
      0, -- calcular desde movimientos
      v_status
    )
    ON CONFLICT (project_id, material_id, analysis_period)
    DO UPDATE SET
      actual_quantity = v_consumed,
      status = v_status,
      analysis_date = CURRENT_DATE;

    -- Crear alerta si hay sobreconsumo crítico
    IF v_status = 'critical' THEN
      INSERT INTO inventory.stock_alerts (
        alert_type,
        severity,
        project_id,
        material_id,
        message,
        current_value,
        threshold_value
      ) VALUES (
        'overconsumption',
        'critical',
        p_project_id,
        v_material.material_id,
        'Sobreconsumo crítico vs presupuesto',
        v_consumed,
        v_budgeted
      );
    END IF;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

2. TypeORM Entities

@Entity('material_stock_config', { schema: 'inventory' })
export class MaterialStockConfig {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'warehouse_id' })
  warehouseId: string;

  @Column({ name: 'material_id' })
  materialId: string;

  @Column({ name: 'minimum_stock', type: 'decimal', precision: 12, scale: 4 })
  minimumStock: number;

  @Column({ name: 'maximum_stock', type: 'decimal', precision: 12, scale: 4, nullable: true })
  maximumStock: number;

  @Column({ name: 'reorder_point', type: 'decimal', precision: 12, scale: 4 })
  reorderPoint: number;

  @Column({ name: 'lead_time_days', default: 7 })
  leadTimeDays: number;

  @Column({ name: 'alert_on_minimum', default: true })
  alertOnMinimum: boolean;

  @Column({ name: 'alert_on_reorder', default: true })
  alertOnReorder: boolean;

  @Column({ name: 'alert_on_overconsumption', default: true })
  alertOnOverconsumption: boolean;

  @Column({ name: 'alert_on_no_movement', default: false })
  alertOnNoMovement: boolean;

  @Column({ name: 'no_movement_days', default: 90 })
  noMovementDays: number;

  @Column({ name: 'notify_users', type: 'uuid', array: true, default: '{}' })
  notifyUsers: string[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}


@Entity('consumption_analysis', { schema: 'inventory' })
export class ConsumptionAnalysis {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'project_id' })
  projectId: string;

  @Column({ name: 'material_id' })
  materialId: string;

  @Column({ name: 'analysis_period', length: 7 })
  analysisPeriod: string;

  @Column({ name: 'budgeted_quantity', type: 'decimal', precision: 12, scale: 4 })
  budgetedQuantity: number;

  @Column({ name: 'actual_quantity', type: 'decimal', precision: 12, scale: 4 })
  actualQuantity: number;

  @Column({ type: 'decimal', precision: 12, scale: 4 })
  variance: number; // GENERATED column

  @Column({ name: 'variance_percentage', type: 'decimal', precision: 6, scale: 2 })
  variancePercentage: number; // GENERATED column

  @Column({ name: 'budgeted_cost', type: 'decimal', precision: 15, scale: 2 })
  budgetedCost: number;

  @Column({ name: 'actual_cost', type: 'decimal', precision: 15, scale: 2 })
  actualCost: number;

  @Column({ name: 'cost_variance', type: 'decimal', precision: 15, scale: 2 })
  costVariance: number; // GENERATED column

  @Column({ name: 'average_weekly_consumption', type: 'decimal', precision: 12, scale: 4, nullable: true })
  averageWeeklyConsumption: number;

  @Column({ name: 'projected_total', type: 'decimal', precision: 12, scale: 4, nullable: true })
  projectedTotal: number;

  @Column({ name: 'projected_cost', type: 'decimal', precision: 15, scale: 2, nullable: true })
  projectedCost: number;

  @Column({ default: 'ok' })
  status: 'ok' | 'warning' | 'critical';

  @Column({ name: 'analysis_date', type: 'date', default: () => 'CURRENT_DATE' })
  analysisDate: Date;

  @Column({ type: 'text', nullable: true })
  notes: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}


@Entity('stock_alerts', { schema: 'inventory' })
export class StockAlert {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'alert_type' })
  alertType: 'minimum_stock' | 'reorder_point' | 'overconsumption' | 'no_movement' | 'maximum_stock';

  @Column({ default: 'warning' })
  severity: 'info' | 'warning' | 'critical';

  @Column({ name: 'warehouse_id', nullable: true })
  warehouseId: string;

  @Column({ name: 'material_id' })
  materialId: string;

  @Column({ name: 'project_id', nullable: true })
  projectId: string;

  @Column({ type: 'text' })
  message: string;

  @Column({ name: 'current_value', type: 'decimal', precision: 12, scale: 4, nullable: true })
  currentValue: number;

  @Column({ name: 'threshold_value', type: 'decimal', precision: 12, scale: 4, nullable: true })
  thresholdValue: number;

  @Column({ type: 'jsonb', nullable: true })
  metadata: any;

  @Column({ default: 'active' })
  status: 'active' | 'acknowledged' | 'resolved';

  @Column({ name: 'notified_users', type: 'uuid', array: true, default: '{}' })
  notifiedUsers: string[];

  @Column({ name: 'acknowledged_by', nullable: true })
  acknowledgedBy: string;

  @Column({ name: 'acknowledged_at', nullable: true })
  acknowledgedAt: Date;

  @Column({ name: 'resolved_by', nullable: true })
  resolvedBy: string;

  @Column({ name: 'resolved_at', nullable: true })
  resolvedAt: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

3. Services (Métodos Clave)

@Injectable()
export class KardexService {
  constructor(
    @InjectRepository(InventoryMovement)
    private movementRepo: Repository<InventoryMovement>,
  ) {}

  async getKardex(
    warehouseId: string,
    materialId: string,
    startDate: Date,
    endDate: Date
  ): Promise<KardexEntry[]> {
    const movements = await this.movementRepo.find({
      where: {
        warehouseId,
        movementDate: Between(startDate, endDate),
      },
      order: { movementDate: 'ASC', createdAt: 'ASC' },
    });

    const kardex: KardexEntry[] = [];
    let runningBalance = await this.getBalanceAt(warehouseId, materialId, startDate);

    for (const movement of movements) {
      const item = movement.items.find(i => i.materialId === materialId);
      if (!item) continue;

      const entry: KardexEntry = {
        date: movement.movementDate,
        movementCode: movement.code,
        movementType: movement.movementType,
        detail: this.getMovementDetail(movement),
        entry: movement.movementType.includes('entry') ? item.quantity : 0,
        exit: movement.movementType.includes('exit') ? item.quantity : 0,
        balance: 0,
        unitCost: item.unitCost,
      };

      runningBalance += entry.entry - entry.exit;
      entry.balance = runningBalance;

      kardex.push(entry);
    }

    return kardex;
  }

  private async getBalanceAt(warehouseId: string, materialId: string, date: Date): Promise<number> {
    const result = await this.movementRepo
      .createQueryBuilder('m')
      .select('SUM(CASE WHEN m.movement_type IN (\'entry\', \'transfer_in\') THEN (item->>\'quantity\')::DECIMAL ELSE 0 END) - SUM(CASE WHEN m.movement_type IN (\'exit\', \'transfer_out\') THEN (item->>\'quantity\')::DECIMAL ELSE 0 END)', 'balance')
      .crossJoin('jsonb_array_elements(m.items)', 'item')
      .where('m.warehouse_id = :warehouseId', { warehouseId })
      .andWhere('(item->>\'materialId\')::UUID = :materialId', { materialId })
      .andWhere('m.movement_date < :date', { date })
      .getRawOne();

    return Number(result.balance) || 0;
  }

  private getMovementDetail(movement: InventoryMovement): string {
    switch (movement.sourceType) {
      case 'purchase_order':
        return `OC-${movement.sourceId.substring(0, 8)}`;
      case 'transfer':
        return `Traspaso`;
      case 'adjustment':
        return `Ajuste de inventario`;
      default:
        return movement.notes || 'Movimiento';
    }
  }
}

interface KardexEntry {
  date: Date;
  movementCode: string;
  movementType: string;
  detail: string;
  entry: number;
  exit: number;
  balance: number;
  unitCost: number;
}


@Injectable()
export class ConsumptionAnalysisService {
  constructor(
    @InjectRepository(ConsumptionAnalysis)
    private analysisRepo: Repository<ConsumptionAnalysis>,
    @InjectDataSource()
    private dataSource: DataSource,
  ) {}

  @Cron(CronExpression.EVERY_DAY_AT_2AM)
  async runDailyAnalysis() {
    const activeProjects = await this.projectRepo.find({
      where: { status: 'execution' },
    });

    const currentPeriod = format(new Date(), 'yyyy-MM');

    for (const project of activeProjects) {
      await this.dataSource.query(
        'SELECT inventory.analyze_consumption($1, $2)',
        [project.id, currentPeriod]
      );
    }
  }

  async getProjectAnalysis(projectId: string, period: string): Promise<ConsumptionAnalysis[]> {
    return await this.analysisRepo.find({
      where: { projectId, analysisPeriod: period },
      order: { variancePercentage: 'DESC' },
    });
  }

  async getOverconsumptionItems(projectId: string): Promise<ConsumptionAnalysis[]> {
    return await this.analysisRepo.find({
      where: {
        projectId,
        status: In(['warning', 'critical']),
      },
      order: { variancePercentage: 'DESC' },
    });
  }
}


@Injectable()
export class StockAlertService {
  constructor(
    @InjectRepository(StockAlert)
    private alertRepo: Repository<StockAlert>,
    @InjectRepository(MaterialStockConfig)
    private configRepo: Repository<MaterialStockConfig>,
    private notificationService: NotificationService,
    private eventEmitter: EventEmitter2,
  ) {}

  @Cron(CronExpression.EVERY_DAY_AT_7AM)
  async checkNoMovementAlerts() {
    const configs = await this.configRepo.find({
      where: { alertOnNoMovement: true },
    });

    for (const config of configs) {
      const stock = await this.stockRepo.findOne({
        where: {
          warehouseId: config.warehouseId,
          materialId: config.materialId,
        },
      });

      if (!stock || !stock.lastMovementDate) continue;

      const daysSinceMovement = differenceInDays(new Date(), stock.lastMovementDate);

      if (daysSinceMovement >= config.noMovementDays) {
        const existingAlert = await this.alertRepo.findOne({
          where: {
            warehouseId: config.warehouseId,
            materialId: config.materialId,
            alertType: 'no_movement',
            status: 'active',
          },
        });

        if (!existingAlert) {
          const alert = await this.alertRepo.save({
            alertType: 'no_movement',
            severity: 'warning',
            warehouseId: config.warehouseId,
            materialId: config.materialId,
            message: `Material sin movimiento por ${daysSinceMovement} días`,
            currentValue: daysSinceMovement,
            thresholdValue: config.noMovementDays,
            notifiedUsers: config.notifyUsers,
            metadata: {
              lastMovementDate: stock.lastMovementDate,
              stockValue: stock.totalValue,
            },
          });

          this.eventEmitter.emit('alert.created', alert);
        }
      }
    }
  }

  async getActiveAlerts(filters?: {
    warehouseId?: string;
    severity?: string;
    alertType?: string;
  }): Promise<StockAlert[]> {
    const where: any = { status: 'active' };

    if (filters?.warehouseId) where.warehouseId = filters.warehouseId;
    if (filters?.severity) where.severity = filters.severity;
    if (filters?.alertType) where.alertType = filters.alertType;

    return await this.alertRepo.find({
      where,
      order: { severity: 'ASC', createdAt: 'DESC' },
    });
  }

  async acknowledgeAlert(alertId: string, userId: string): Promise<StockAlert> {
    const alert = await this.alertRepo.findOneOrFail({ where: { id: alertId } });

    alert.status = 'acknowledged';
    alert.acknowledgedBy = userId;
    alert.acknowledgedAt = new Date();

    return await this.alertRepo.save(alert);
  }

  async resolveAlert(alertId: string, userId: string): Promise<StockAlert> {
    const alert = await this.alertRepo.findOneOrFail({ where: { id: alertId } });

    alert.status = 'resolved';
    alert.resolvedBy = userId;
    alert.resolvedAt = new Date();

    return await this.alertRepo.save(alert);
  }

  @OnEvent('alert.created')
  async handleAlertCreated(alert: StockAlert) {
    for (const userId of alert.notifiedUsers) {
      await this.notificationService.create({
        userId,
        type: 'stock_alert',
        title: this.getAlertTitle(alert.alertType),
        message: alert.message,
        data: {
          alertId: alert.id,
          severity: alert.severity,
        },
      });
    }
  }

  private getAlertTitle(alertType: string): string {
    const titles = {
      minimum_stock: 'Stock Crítico',
      reorder_point: 'Punto de Reorden',
      overconsumption: 'Sobreconsumo Detectado',
      no_movement: 'Material Sin Movimiento',
      maximum_stock: 'Stock Excedido',
    };
    return titles[alertType] || 'Alerta de Inventario';
  }
}


@Injectable()
export class InventoryDashboardService {
  async getDashboard(constructoraId: string): Promise<InventoryDashboardDto> {
    // Almacenes activos
    const warehouses = await this.warehouseRepo.count({
      where: { constructoraId, isActive: true },
    });

    // Materiales en stock
    const materials = await this.stockRepo.count({
      where: { quantity: MoreThan(0) },
    });

    // Valor total
    const totalValue = await this.stockRepo
      .createQueryBuilder('s')
      .select('SUM(s.total_value)', 'total')
      .where('s.warehouse_id IN (SELECT id FROM inventory.warehouses WHERE constructora_id = :constructoraId)', { constructoraId })
      .getRawOne();

    // Alertas activas
    const alerts = await this.alertRepo
      .createQueryBuilder('a')
      .select('a.alert_type, a.severity, COUNT(*) as count')
      .where('a.status = :status', { status: 'active' })
      .groupBy('a.alert_type, a.severity')
      .getRawMany();

    // Top 5 materiales por valor
    const topMaterials = await this.stockRepo.find({
      order: { totalValue: 'DESC' },
      take: 5,
    });

    return {
      summary: {
        activeWarehouses: warehouses,
        materialsInStock: materials,
        totalInventoryValue: totalValue.total,
      },
      alerts: this.groupAlertsByType(alerts),
      topMaterials,
    };
  }

  private groupAlertsByType(alerts: any[]) {
    return {
      critical: alerts.filter(a => a.severity === 'critical').reduce((sum, a) => sum + Number(a.count), 0),
      reorder: alerts.filter(a => a.alert_type === 'reorder_point').reduce((sum, a) => sum + Number(a.count), 0),
      overconsumption: alerts.filter(a => a.alert_type === 'overconsumption').reduce((sum, a) => sum + Number(a.count), 0),
      noMovement: alerts.filter(a => a.alert_type === 'no_movement').reduce((sum, a) => sum + Number(a.count), 0),
    };
  }
}

4. React Components (Dashboard)

export const InventoryDashboard: React.FC = () => {
  const { data: dashboard } = useQuery(['inventory-dashboard'], () =>
    api.get('/inventory/dashboard').then(r => r.data)
  );

  return (
    <div className="dashboard-grid">
      <Card title="Resumen General">
        <div className="metrics">
          <Metric
            label="Almacenes activos"
            value={dashboard?.summary.activeWarehouses}
          />
          <Metric
            label="Materiales en stock"
            value={dashboard?.summary.materialsInStock}
          />
          <Metric
            label="Valor total inventario"
            value={formatCurrency(dashboard?.summary.totalInventoryValue)}
          />
        </div>
      </Card>

      <Card title="Alertas Activas">
        <div className="alerts-summary">
          <AlertBadge
            type="critical"
            count={dashboard?.alerts.critical}
            label="Stock crítico"
          />
          <AlertBadge
            type="warning"
            count={dashboard?.alerts.reorder}
            label="Punto reorden"
          />
          <AlertBadge
            type="warning"
            count={dashboard?.alerts.overconsumption}
            label="Sobreconsumo"
          />
          <AlertBadge
            type="info"
            count={dashboard?.alerts.noMovement}
            label="Sin movimiento 90d"
          />
        </div>
      </Card>

      <Card title="Top 5 Materiales por Valor">
        <MaterialValueChart data={dashboard?.topMaterials} />
      </Card>
    </div>
  );
};


export const KardexView: React.FC<{ warehouseId: string; materialId: string }> = ({
  warehouseId,
  materialId,
}) => {
  const [dateRange, setDateRange] = useState({
    start: startOfMonth(new Date()),
    end: endOfMonth(new Date()),
  });

  const { data: kardex } = useQuery(
    ['kardex', warehouseId, materialId, dateRange],
    () => api.get('/inventory/kardex', {
      params: { warehouseId, materialId, ...dateRange }
    }).then(r => r.data)
  );

  return (
    <div className="kardex-view">
      <div className="header">
        <h2>Kárdex - {kardex?.materialName}</h2>
        <DateRangePicker value={dateRange} onChange={setDateRange} />
      </div>

      <table className="kardex-table">
        <thead>
          <tr>
            <th>Fecha</th>
            <th>Movimiento</th>
            <th>Detalle</th>
            <th>Entrada</th>
            <th>Salida</th>
            <th>Saldo</th>
            <th>Costo U.</th>
          </tr>
        </thead>
        <tbody>
          {kardex?.entries.map((entry, i) => (
            <tr key={i}>
              <td>{format(entry.date, 'dd/MM/yyyy')}</td>
              <td>{entry.movementCode}</td>
              <td>{entry.detail}</td>
              <td className="entry">{entry.entry > 0 && formatNumber(entry.entry)}</td>
              <td className="exit">{entry.exit > 0 && formatNumber(entry.exit)}</td>
              <td className="balance">{formatNumber(entry.balance)}</td>
              <td>{formatCurrency(entry.unitCost)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

Estado: Ready for Implementation