# 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 ```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 ```typescript @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) ```typescript @Injectable() export class KardexService { constructor( @InjectRepository(InventoryMovement) private movementRepo: Repository, ) {} async getKardex( warehouseId: string, materialId: string, startDate: Date, endDate: Date ): Promise { 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 { 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, @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 { return await this.analysisRepo.find({ where: { projectId, analysisPeriod: period }, order: { variancePercentage: 'DESC' }, }); } async getOverconsumptionItems(projectId: string): Promise { return await this.analysisRepo.find({ where: { projectId, status: In(['warning', 'critical']), }, order: { variancePercentage: 'DESC' }, }); } } @Injectable() export class StockAlertService { constructor( @InjectRepository(StockAlert) private alertRepo: Repository, @InjectRepository(MaterialStockConfig) private configRepo: Repository, 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 { 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 { 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 { 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 { // 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) ```typescript export const InventoryDashboard: React.FC = () => { const { data: dashboard } = useQuery(['inventory-dashboard'], () => api.get('/inventory/dashboard').then(r => r.data) ); return (
); }; 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 (

Kárdex - {kardex?.materialName}

{kardex?.entries.map((entry, i) => ( ))}
Fecha Movimiento Detalle Entrada Salida Saldo Costo U.
{format(entry.date, 'dd/MM/yyyy')} {entry.movementCode} {entry.detail} {entry.entry > 0 && formatNumber(entry.entry)} {entry.exit > 0 && formatNumber(entry.exit)} {formatNumber(entry.balance)} {formatCurrency(entry.unitCost)}
); }; ``` --- **Estado:** ✅ Ready for Implementation