# ET-PURCH-003: Implementación de Almacenes e Inventarios **Épica:** MAI-004 - Compras e Inventarios **Versión:** 1.0 **Fecha:** 2025-11-17 --- ## 1. Schemas SQL ```sql CREATE SCHEMA IF NOT EXISTS inventory; -- Tabla: warehouses CREATE TABLE inventory.warehouses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(20) NOT NULL UNIQUE, constructora_id UUID NOT NULL REFERENCES public.constructoras(id), name VARCHAR(255) NOT NULL, warehouse_type VARCHAR(20) DEFAULT 'general' CHECK (warehouse_type IN ('general', 'project', 'temporary')), project_id UUID REFERENCES projects.projects(id), address TEXT, city VARCHAR(100), state VARCHAR(100), managed_by UUID REFERENCES auth.users(id), total_area DECIMAL(10,2), covered_area DECIMAL(10,2), is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT warehouse_project_check CHECK ( (warehouse_type = 'project' AND project_id IS NOT NULL) OR (warehouse_type != 'project' AND project_id IS NULL) ) ); CREATE INDEX idx_warehouses_constructora ON inventory.warehouses(constructora_id); CREATE INDEX idx_warehouses_type ON inventory.warehouses(warehouse_type); CREATE INDEX idx_warehouses_project ON inventory.warehouses(project_id); -- Tabla: warehouse_locations CREATE TABLE inventory.warehouse_locations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE, zone VARCHAR(10) NOT NULL, -- A, B, C position VARCHAR(10) NOT NULL, -- 01, 02, 03 code VARCHAR(20) NOT NULL, -- A-01, B-03 description VARCHAR(255), capacity_m3 DECIMAL(10,2), is_active BOOLEAN DEFAULT true, CONSTRAINT unique_warehouse_location UNIQUE (warehouse_id, code) ); CREATE INDEX idx_locations_warehouse ON inventory.warehouse_locations(warehouse_id); -- Tabla: inventory_movements CREATE TABLE inventory.inventory_movements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(20) NOT NULL UNIQUE, constructora_id UUID NOT NULL REFERENCES public.constructoras(id), warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), movement_type VARCHAR(20) NOT NULL CHECK (movement_type IN ('entry', 'exit', 'transfer_out', 'transfer_in', 'adjustment')), movement_date DATE DEFAULT CURRENT_DATE, -- Origen del movimiento source_type VARCHAR(30) CHECK (source_type IN ('purchase_order', 'transfer', 'return', 'adjustment', 'production')), source_id UUID, -- Para salidas project_id UUID REFERENCES projects.projects(id), budget_item_id UUID, -- Para traspasos transfer_to_warehouse_id UUID REFERENCES inventory.warehouses(id), transfer_from_warehouse_id UUID REFERENCES inventory.warehouses(id), items JSONB NOT NULL, /* Estructura items: [{ materialId: UUID, quantity: number, unit: string, unitCost: number, totalCost: number, lotId: UUID, locationId: UUID }] */ total_value DECIMAL(15,2) NOT NULL, notes TEXT, authorized_by UUID REFERENCES auth.users(id), recorded_by UUID NOT NULL REFERENCES auth.users(id), -- Para traspasos transfer_status VARCHAR(20) CHECK (transfer_status IN ('pending', 'in_transit', 'received', 'cancelled')), received_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_movements_warehouse ON inventory.inventory_movements(warehouse_id); CREATE INDEX idx_movements_type ON inventory.inventory_movements(movement_type); CREATE INDEX idx_movements_date ON inventory.inventory_movements(movement_date); CREATE INDEX idx_movements_project ON inventory.inventory_movements(project_id); CREATE INDEX idx_movements_source ON inventory.inventory_movements(source_type, source_id); -- Tabla: inventory_stock CREATE TABLE inventory.inventory_stock ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), material_id UUID NOT NULL, location_id UUID REFERENCES inventory.warehouse_locations(id), quantity DECIMAL(12,4) NOT NULL DEFAULT 0, reserved_quantity DECIMAL(12,4) DEFAULT 0, available_quantity DECIMAL(12,4) GENERATED ALWAYS AS (quantity - reserved_quantity) STORED, average_cost DECIMAL(12,2) NOT NULL DEFAULT 0, total_value DECIMAL(15,2) GENERATED ALWAYS AS (quantity * average_cost) STORED, last_movement_date DATE, last_entry_date DATE, last_exit_date DATE, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_warehouse_material UNIQUE (warehouse_id, material_id), CONSTRAINT check_quantity_positive CHECK (quantity >= 0), CONSTRAINT check_reserved_valid CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity) ); CREATE INDEX idx_stock_warehouse ON inventory.inventory_stock(warehouse_id); CREATE INDEX idx_stock_material ON inventory.inventory_stock(material_id); CREATE INDEX idx_stock_location ON inventory.inventory_stock(location_id); CREATE INDEX idx_stock_last_movement ON inventory.inventory_stock(last_movement_date); -- Tabla: inventory_lots (PEPS) CREATE TABLE inventory.inventory_lots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), material_id UUID NOT NULL, lot_number VARCHAR(50), entry_date DATE NOT NULL DEFAULT CURRENT_DATE, quantity DECIMAL(12,4) NOT NULL, remaining_quantity DECIMAL(12,4) NOT NULL, unit_cost DECIMAL(12,2) NOT NULL, source_type VARCHAR(30) NOT NULL, source_id UUID NOT NULL, is_depleted BOOLEAN GENERATED ALWAYS AS (remaining_quantity <= 0) STORED, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT check_remaining_valid CHECK (remaining_quantity >= 0 AND remaining_quantity <= quantity) ); CREATE INDEX idx_lots_warehouse_material ON inventory.inventory_lots(warehouse_id, material_id, entry_date); CREATE INDEX idx_lots_depleted ON inventory.inventory_lots(is_depleted) WHERE is_depleted = false; -- Tabla: physical_inventories CREATE TABLE inventory.physical_inventories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(20) NOT NULL UNIQUE, warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), inventory_date DATE NOT NULL, status VARCHAR(20) DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'completed', 'cancelled')), counted_by UUID[] NOT NULL, supervised_by UUID REFERENCES auth.users(id), total_items INTEGER DEFAULT 0, items_counted INTEGER DEFAULT 0, items_with_variance INTEGER DEFAULT 0, total_variance_value DECIMAL(15,2) DEFAULT 0, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP ); CREATE INDEX idx_physical_inv_warehouse ON inventory.physical_inventories(warehouse_id); CREATE INDEX idx_physical_inv_date ON inventory.physical_inventories(inventory_date); -- Tabla: physical_inventory_items CREATE TABLE inventory.physical_inventory_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), physical_inventory_id UUID NOT NULL REFERENCES inventory.physical_inventories(id) ON DELETE CASCADE, material_id UUID NOT NULL, system_quantity DECIMAL(12,4) NOT NULL, physical_quantity DECIMAL(12,4), variance DECIMAL(12,4) GENERATED ALWAYS AS (physical_quantity - system_quantity) STORED, variance_percentage DECIMAL(6,2), unit_cost DECIMAL(12,2) NOT NULL, variance_value DECIMAL(15,2) GENERATED ALWAYS AS (variance * unit_cost) STORED, status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'counted', 'verified', 'adjusted')), counted_by UUID REFERENCES auth.users(id), counted_at TIMESTAMP, notes TEXT, CONSTRAINT unique_inventory_material UNIQUE (physical_inventory_id, material_id) ); CREATE INDEX idx_physical_items_inventory ON inventory.physical_inventory_items(physical_inventory_id); CREATE INDEX idx_physical_items_variance ON inventory.physical_inventory_items(variance) WHERE variance != 0; -- Función: Actualizar stock al crear movimiento CREATE OR REPLACE FUNCTION inventory.update_stock_on_movement() RETURNS TRIGGER AS $$ DECLARE v_item JSONB; v_material_id UUID; v_quantity DECIMAL(12,4); v_unit_cost DECIMAL(12,2); BEGIN FOR v_item IN SELECT * FROM jsonb_array_elements(NEW.items) LOOP v_material_id := (v_item->>'materialId')::UUID; v_quantity := (v_item->>'quantity')::DECIMAL(12,4); v_unit_cost := (v_item->>'unitCost')::DECIMAL(12,2); IF NEW.movement_type IN ('entry', 'transfer_in') THEN -- Entrada: Incrementar stock INSERT INTO inventory.inventory_stock ( warehouse_id, material_id, quantity, average_cost, last_movement_date, last_entry_date ) VALUES ( NEW.warehouse_id, v_material_id, v_quantity, v_unit_cost, NEW.movement_date, NEW.movement_date ) ON CONFLICT (warehouse_id, material_id) DO UPDATE SET quantity = inventory_stock.quantity + v_quantity, average_cost = ((inventory_stock.quantity * inventory_stock.average_cost) + (v_quantity * v_unit_cost)) / (inventory_stock.quantity + v_quantity), last_movement_date = NEW.movement_date, last_entry_date = NEW.movement_date, updated_at = CURRENT_TIMESTAMP; -- Crear lote PEPS INSERT INTO inventory.inventory_lots ( warehouse_id, material_id, lot_number, entry_date, quantity, remaining_quantity, unit_cost, source_type, source_id ) VALUES ( NEW.warehouse_id, v_material_id, NEW.code, NEW.movement_date, v_quantity, v_quantity, v_unit_cost, NEW.source_type, NEW.source_id ); ELSIF NEW.movement_type IN ('exit', 'transfer_out') THEN -- Salida: Decrementar stock usando PEPS PERFORM inventory.process_exit_peps(NEW.warehouse_id, v_material_id, v_quantity); UPDATE inventory.inventory_stock SET quantity = quantity - v_quantity, last_movement_date = NEW.movement_date, last_exit_date = NEW.movement_date, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = NEW.warehouse_id AND material_id = v_material_id; ELSIF NEW.movement_type = 'adjustment' THEN -- Ajuste: Modificar directamente UPDATE inventory.inventory_stock SET quantity = quantity + v_quantity, -- puede ser negativo last_movement_date = NEW.movement_date, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = NEW.warehouse_id AND material_id = v_material_id; END IF; END LOOP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_stock AFTER INSERT ON inventory.inventory_movements FOR EACH ROW EXECUTE FUNCTION inventory.update_stock_on_movement(); -- Función: Procesar salida PEPS CREATE OR REPLACE FUNCTION inventory.process_exit_peps( p_warehouse_id UUID, p_material_id UUID, p_quantity DECIMAL(12,4) ) RETURNS VOID AS $$ DECLARE v_lot RECORD; v_remaining DECIMAL(12,4); v_to_consume DECIMAL(12,4); BEGIN v_remaining := p_quantity; FOR v_lot IN SELECT * FROM inventory.inventory_lots WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id AND remaining_quantity > 0 ORDER BY entry_date ASC, created_at ASC LOOP EXIT WHEN v_remaining <= 0; v_to_consume := LEAST(v_lot.remaining_quantity, v_remaining); UPDATE inventory.inventory_lots SET remaining_quantity = remaining_quantity - v_to_consume WHERE id = v_lot.id; v_remaining := v_remaining - v_to_consume; END LOOP; IF v_remaining > 0 THEN RAISE EXCEPTION 'Stock insuficiente para material % en almacén %', p_material_id, p_warehouse_id; END IF; END; $$ LANGUAGE plpgsql; -- Función: Generar código de movimiento CREATE OR REPLACE FUNCTION inventory.generate_movement_code() RETURNS TRIGGER AS $$ DECLARE v_prefix VARCHAR(5); v_year VARCHAR(4); v_sequence INTEGER; BEGIN v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); v_prefix := CASE NEW.movement_type WHEN 'entry' THEN 'ENT' WHEN 'exit' THEN 'SAL' WHEN 'transfer_out' THEN 'TRA' WHEN 'transfer_in' THEN 'TRA' WHEN 'adjustment' THEN 'AJU' END; SELECT COALESCE(MAX(SUBSTRING(code FROM LENGTH(v_prefix) + 6)::INTEGER), 0) + 1 INTO v_sequence FROM inventory.inventory_movements WHERE code LIKE v_prefix || '-' || v_year || '-%'; NEW.code := v_prefix || '-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_generate_movement_code BEFORE INSERT ON inventory.inventory_movements FOR EACH ROW WHEN (NEW.code IS NULL) EXECUTE FUNCTION inventory.generate_movement_code(); ``` ## 2. TypeORM Entities ```typescript @Entity('warehouses', { schema: 'inventory' }) export class Warehouse { @PrimaryGeneratedColumn('uuid') id: string; @Column({ length: 20, unique: true }) code: string; @Column({ name: 'constructora_id' }) constructoraId: string; @Column({ length: 255 }) name: string; @Column({ name: 'warehouse_type', default: 'general' }) warehouseType: 'general' | 'project' | 'temporary'; @Column({ name: 'project_id', nullable: true }) projectId: string; @Column({ type: 'text', nullable: true }) address: string; @Column({ nullable: true }) city: string; @Column({ nullable: true }) state: string; @Column({ name: 'managed_by', nullable: true }) managedBy: string; @Column({ name: 'total_area', type: 'decimal', precision: 10, scale: 2, nullable: true }) totalArea: number; @Column({ name: 'covered_area', type: 'decimal', precision: 10, scale: 2, nullable: true }) coveredArea: number; @Column({ name: 'is_active', default: true }) isActive: boolean; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @OneToMany(() => WarehouseLocation, location => location.warehouse) locations: WarehouseLocation[]; @OneToMany(() => InventoryStock, stock => stock.warehouse) stock: InventoryStock[]; } @Entity('warehouse_locations', { schema: 'inventory' }) export class WarehouseLocation { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @Column({ length: 10 }) zone: string; @Column({ length: 10 }) position: string; @Column({ length: 20 }) code: string; @Column({ nullable: true }) description: string; @Column({ name: 'capacity_m3', type: 'decimal', precision: 10, scale: 2, nullable: true }) capacityM3: number; @Column({ name: 'is_active', default: true }) isActive: boolean; @ManyToOne(() => Warehouse, warehouse => warehouse.locations) warehouse: Warehouse; } @Entity('inventory_movements', { schema: 'inventory' }) export class InventoryMovement { @PrimaryGeneratedColumn('uuid') id: string; @Column({ length: 20, unique: true }) code: string; @Column({ name: 'constructora_id' }) constructoraId: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @Column({ name: 'movement_type' }) movementType: 'entry' | 'exit' | 'transfer_out' | 'transfer_in' | 'adjustment'; @Column({ name: 'movement_date', type: 'date', default: () => 'CURRENT_DATE' }) movementDate: Date; @Column({ name: 'source_type', nullable: true }) sourceType: 'purchase_order' | 'transfer' | 'return' | 'adjustment' | 'production'; @Column({ name: 'source_id', nullable: true }) sourceId: string; @Column({ name: 'project_id', nullable: true }) projectId: string; @Column({ name: 'budget_item_id', nullable: true }) budgetItemId: string; @Column({ name: 'transfer_to_warehouse_id', nullable: true }) transferToWarehouseId: string; @Column({ name: 'transfer_from_warehouse_id', nullable: true }) transferFromWarehouseId: string; @Column({ type: 'jsonb' }) items: InventoryMovementItem[]; @Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2 }) totalValue: number; @Column({ type: 'text', nullable: true }) notes: string; @Column({ name: 'authorized_by', nullable: true }) authorizedBy: string; @Column({ name: 'recorded_by' }) recordedBy: string; @Column({ name: 'transfer_status', nullable: true }) transferStatus: 'pending' | 'in_transit' | 'received' | 'cancelled'; @Column({ name: 'received_at', nullable: true }) receivedAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } interface InventoryMovementItem { materialId: string; quantity: number; unit: string; unitCost: number; totalCost: number; lotId?: string; locationId?: string; } @Entity('inventory_stock', { schema: 'inventory' }) export class InventoryStock { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @Column({ name: 'material_id' }) materialId: string; @Column({ name: 'location_id', nullable: true }) locationId: string; @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) quantity: number; @Column({ name: 'reserved_quantity', type: 'decimal', precision: 12, scale: 4, default: 0 }) reservedQuantity: number; @Column({ name: 'available_quantity', type: 'decimal', precision: 12, scale: 4 }) availableQuantity: number; // GENERATED column @Column({ name: 'average_cost', type: 'decimal', precision: 12, scale: 2, default: 0 }) averageCost: number; @Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2 }) totalValue: number; // GENERATED column @Column({ name: 'last_movement_date', type: 'date', nullable: true }) lastMovementDate: Date; @Column({ name: 'last_entry_date', type: 'date', nullable: true }) lastEntryDate: Date; @Column({ name: 'last_exit_date', type: 'date', nullable: true }) lastExitDate: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @ManyToOne(() => Warehouse, warehouse => warehouse.stock) warehouse: Warehouse; } @Entity('physical_inventories', { schema: 'inventory' }) export class PhysicalInventory { @PrimaryGeneratedColumn('uuid') id: string; @Column({ length: 20, unique: true }) code: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @Column({ name: 'inventory_date', type: 'date' }) inventoryDate: Date; @Column({ default: 'in_progress' }) status: 'in_progress' | 'completed' | 'cancelled'; @Column({ name: 'counted_by', type: 'uuid', array: true }) countedBy: string[]; @Column({ name: 'supervised_by', nullable: true }) supervisedBy: string; @Column({ name: 'total_items', default: 0 }) totalItems: number; @Column({ name: 'items_counted', default: 0 }) itemsCounted: number; @Column({ name: 'items_with_variance', default: 0 }) itemsWithVariance: number; @Column({ name: 'total_variance_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) totalVarianceValue: number; @Column({ type: 'text', nullable: true }) notes: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @Column({ name: 'completed_at', nullable: true }) completedAt: Date; @OneToMany(() => PhysicalInventoryItem, item => item.physicalInventory) items: PhysicalInventoryItem[]; } @Entity('physical_inventory_items', { schema: 'inventory' }) export class PhysicalInventoryItem { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'physical_inventory_id' }) physicalInventoryId: string; @Column({ name: 'material_id' }) materialId: string; @Column({ name: 'system_quantity', type: 'decimal', precision: 12, scale: 4 }) systemQuantity: number; @Column({ name: 'physical_quantity', type: 'decimal', precision: 12, scale: 4, nullable: true }) physicalQuantity: number; @Column({ type: 'decimal', precision: 12, scale: 4 }) variance: number; // GENERATED column @Column({ name: 'variance_percentage', type: 'decimal', precision: 6, scale: 2, nullable: true }) variancePercentage: number; @Column({ name: 'unit_cost', type: 'decimal', precision: 12, scale: 2 }) unitCost: number; @Column({ name: 'variance_value', type: 'decimal', precision: 15, scale: 2 }) varianceValue: number; // GENERATED column @Column({ default: 'pending' }) status: 'pending' | 'counted' | 'verified' | 'adjusted'; @Column({ name: 'counted_by', nullable: true }) countedBy: string; @Column({ name: 'counted_at', nullable: true }) countedAt: Date; @Column({ type: 'text', nullable: true }) notes: string; @ManyToOne(() => PhysicalInventory, inventory => inventory.items) physicalInventory: PhysicalInventory; } ``` ## 3. Services (Métodos Clave) ```typescript @Injectable() export class InventoryMovementService { constructor( @InjectRepository(InventoryMovement) private movementRepo: Repository, @InjectRepository(InventoryStock) private stockRepo: Repository, private eventEmitter: EventEmitter2, ) {} async registerEntry(dto: CreateEntryDto, userId: string): Promise { const totalValue = dto.items.reduce((sum, item) => sum + item.totalCost, 0); const movement = this.movementRepo.create({ ...dto, movementType: 'entry', recordedBy: userId, totalValue, }); await this.movementRepo.save(movement); // El trigger actualiza automáticamente el stock this.eventEmitter.emit('inventory.entry_registered', { warehouseId: dto.warehouseId, items: dto.items, }); return movement; } async registerExit(dto: CreateExitDto, userId: string): Promise { // Validar stock disponible for (const item of dto.items) { const stock = await this.stockRepo.findOne({ where: { warehouseId: dto.warehouseId, materialId: item.materialId, }, }); if (!stock || stock.availableQuantity < item.quantity) { throw new BadRequestException( `Stock insuficiente para material ${item.materialId}` ); } } // Calcular costo usando PEPS (se hace en trigger) const totalValue = await this.calculateExitValue(dto.warehouseId, dto.items); const movement = this.movementRepo.create({ ...dto, movementType: 'exit', recordedBy: userId, totalValue, }); await this.movementRepo.save(movement); // Afectar presupuesto si es salida para obra if (dto.budgetItemId) { this.eventEmitter.emit('budget.material_consumed', { budgetItemId: dto.budgetItemId, amount: totalValue, }); } return movement; } async createTransfer(dto: CreateTransferDto, userId: string): Promise<{out: InventoryMovement, in: InventoryMovement}> { // Crear salida del almacén origen const outMovement = await this.registerExit({ warehouseId: dto.fromWarehouseId, items: dto.items, notes: `Traspaso a ${dto.toWarehouseName}`, }, userId); // Crear entrada al almacén destino (en tránsito) const inMovement = this.movementRepo.create({ warehouseId: dto.toWarehouseId, movementType: 'transfer_in', sourceType: 'transfer', sourceId: outMovement.id, transferFromWarehouseId: dto.fromWarehouseId, items: dto.items, totalValue: outMovement.totalValue, recordedBy: userId, transferStatus: 'in_transit', }); await this.movementRepo.save(inMovement); return { out: outMovement, in: inMovement }; } async receiveTransfer(transferId: string, userId: string): Promise { const transfer = await this.movementRepo.findOneOrFail({ where: { id: transferId, movementType: 'transfer_in' }, }); transfer.transferStatus = 'received'; transfer.receivedAt = new Date(); await this.movementRepo.save(transfer); // El trigger actualiza el stock return transfer; } private async calculateExitValue(warehouseId: string, items: any[]): Promise { let total = 0; for (const item of items) { const lots = await this.lotRepo.find({ where: { warehouseId, materialId: item.materialId, remainingQuantity: MoreThan(0), }, order: { entryDate: 'ASC', createdAt: 'ASC' }, }); let remaining = item.quantity; for (const lot of lots) { if (remaining <= 0) break; const consume = Math.min(lot.remainingQuantity, remaining); total += consume * lot.unitCost; remaining -= consume; } } return total; } } @Injectable() export class PhysicalInventoryService { constructor( @InjectRepository(PhysicalInventory) private inventoryRepo: Repository, @InjectRepository(PhysicalInventoryItem) private itemRepo: Repository, @InjectRepository(InventoryStock) private stockRepo: Repository, ) {} async create(dto: CreatePhysicalInventoryDto): Promise { // Obtener stock actual del almacén const currentStock = await this.stockRepo.find({ where: { warehouseId: dto.warehouseId }, }); const inventory = this.inventoryRepo.create({ warehouseId: dto.warehouseId, inventoryDate: dto.inventoryDate, countedBy: dto.countedBy, supervisedBy: dto.supervisedBy, totalItems: currentStock.length, status: 'in_progress', }); await this.inventoryRepo.save(inventory); // Crear items para conteo const items = currentStock.map(stock => this.itemRepo.create({ physicalInventoryId: inventory.id, materialId: stock.materialId, systemQuantity: stock.quantity, unitCost: stock.averageCost, status: 'pending', }) ); await this.itemRepo.save(items); return inventory; } async recordCount(itemId: string, physicalQuantity: number, userId: string): Promise { const item = await this.itemRepo.findOneOrFail({ where: { id: itemId } }); item.physicalQuantity = physicalQuantity; item.variancePercentage = ((physicalQuantity - item.systemQuantity) / item.systemQuantity) * 100; item.status = 'counted'; item.countedBy = userId; item.countedAt = new Date(); await this.itemRepo.save(item); // Actualizar contadores del inventario await this.inventoryRepo.increment( { id: item.physicalInventoryId }, 'itemsCounted', 1 ); if (item.variance !== 0) { await this.inventoryRepo.increment( { id: item.physicalInventoryId }, 'itemsWithVariance', 1 ); } return item; } async complete(inventoryId: string): Promise { const inventory = await this.inventoryRepo.findOneOrFail({ where: { id: inventoryId }, relations: ['items'], }); const totalVariance = inventory.items.reduce( (sum, item) => sum + (item.varianceValue || 0), 0 ); inventory.status = 'completed'; inventory.completedAt = new Date(); inventory.totalVarianceValue = totalVariance; return await this.inventoryRepo.save(inventory); } async generateAdjustments(inventoryId: string, userId: string): Promise { const inventory = await this.inventoryRepo.findOneOrFail({ where: { id: inventoryId, status: 'completed' }, relations: ['items'], }); const adjustments: InventoryMovement[] = []; // Agrupar por tipo de ajuste const itemsToAdjust = inventory.items.filter(item => item.variance !== 0); for (const item of itemsToAdjust) { const movement = await this.movementService.registerAdjustment({ warehouseId: inventory.warehouseId, items: [{ materialId: item.materialId, quantity: item.variance, // puede ser negativo unitCost: item.unitCost, totalCost: item.varianceValue, }], notes: `Ajuste por inventario físico ${inventory.code}`, }, userId); adjustments.push(movement); } return adjustments; } } ``` --- **Estado:** ✅ Ready for Implementation