27 KiB
27 KiB
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
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
@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)
@Injectable()
export class InventoryMovementService {
constructor(
@InjectRepository(InventoryMovement)
private movementRepo: Repository<InventoryMovement>,
@InjectRepository(InventoryStock)
private stockRepo: Repository<InventoryStock>,
private eventEmitter: EventEmitter2,
) {}
async registerEntry(dto: CreateEntryDto, userId: string): Promise<InventoryMovement> {
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<InventoryMovement> {
// 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<InventoryMovement> {
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<number> {
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<PhysicalInventory>,
@InjectRepository(PhysicalInventoryItem)
private itemRepo: Repository<PhysicalInventoryItem>,
@InjectRepository(InventoryStock)
private stockRepo: Repository<InventoryStock>,
) {}
async create(dto: CreatePhysicalInventoryDto): Promise<PhysicalInventory> {
// 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<PhysicalInventoryItem> {
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<PhysicalInventory> {
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<InventoryMovement[]> {
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