import { Request, Response } from 'express'; import StockItem from '../models/StockItem.js'; import StockMovement from '../models/StockMovement.js'; import { IAppUser } from '../middleware/roleMiddleware.js'; import { notificationService } from '../services/notificationService.js'; interface AuthRequest extends Request { appUser?: IAppUser; } export const createStockItem = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { dataSheetId, rrNumber, batchNumber, quantity, unit, expirationDate, notes, color, invoiceNumber, receivedBy, minStock } = req.body; // Validation if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) { return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' }); } // Check for duplicate RR within Org const existing = await StockItem.findOne({ organizationId, rrNumber }); if (existing) { return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` }); } // --- Min Stock Inheritance Logic --- let finalMinStock = Number(minStock) || 0; // If user didn't provide a specific minStock (or provided 0), try to inherit from existing group if (finalMinStock === 0) { const existingGroupItem = await StockItem.findOne({ organizationId, dataSheetId, color }).sort({ updatedAt: -1 }); // Get latest active config if (existingGroupItem && existingGroupItem.minStock > 0) { finalMinStock = existingGroupItem.minStock; } } else { // If user DID provide a minStock, update all existing items in that group to match? // User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros" // And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas" // This implies the rule is a Property of the Group. So create/update should enforce consistency. if (finalMinStock > 0) { await StockItem.updateMany( { organizationId, dataSheetId, color }, { $set: { minStock: finalMinStock } } ); } } const newItem = new StockItem({ organizationId, createdBy: req.appUser?._id, dataSheetId, rrNumber, batchNumber, quantity: Number(quantity), unit, minStock: finalMinStock, expirationDate, notes, color, invoiceNumber, receivedBy }); const savedItem = await newItem.save(); // Create Initial Movement (ENTRY) await StockMovement.create({ organizationId, createdBy: req.appUser?._id, stockItemId: savedItem._id, movementNumber: 1, type: 'ENTRY', quantity: Number(quantity), responsible: userName, notes: 'Abertura de Lote / Entrada Inicial' }); // Notificação de Recebimento if (organizationId) { await notificationService.create({ organizationId, title: 'Recebimento de Material', message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`, type: 'info', metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' } }); } // Check Low Stock immediately await notificationService.checkLowStock(savedItem._id.toString()); res.status(201).json(savedItem); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error creating stock item:', error); res.status(500).json({ error: message }); } }; export const updateStockItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; // Only allow updating metadata, NOT quantity directly (quantity must be via adjustments) // Adjusting logic: Admin might need to fix typo in quantity without movement record? // Better enforcing movements. If quantity changes, user should use "Adjustment". // Here we create a general update for details like Notes, Dates, etc. const { quantity, ...otherData } = req.body; // Separate quantity if (quantity !== undefined) { return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' }); } // Check if Min Stock is being updated if (otherData.minStock !== undefined) { const item = await StockItem.findOne({ _id: id, organizationId }); if (item) { // Propagate to all siblings (same Product + Color) await StockItem.updateMany( { organizationId, dataSheetId: item.dataSheetId, color: item.color }, { $set: { minStock: otherData.minStock } } ); } } const updated = await StockItem.findOneAndUpdate( { _id: id, organizationId }, otherData, { new: true } ); if (!updated) return res.status(404).json({ error: 'Item não encontrado.' }); // Check Low Stock (in case minStock changed) await notificationService.checkLowStock(updated._id.toString()); res.json(updated); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const adjustStock = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5 if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' }); if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' }); const item = await StockItem.findOne({ _id: id, organizationId }); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); // Calculate new quantity const newQuantity = Number(item.quantity) + Number(quantityDelta); if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' }); item.quantity = newQuantity; await item.save(); // Calculate next movement number const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 }); const count = await StockMovement.countDocuments({ stockItemId: item._id }); const movementNumber = (lastMov?.movementNumber || count) + 1; // Register Movement await StockMovement.create({ organizationId, createdBy: req.appUser?._id, stockItemId: item._id, movementNumber, type: 'ADJUSTMENT', quantity: Number(quantityDelta), responsible: userName, reason }); // Check Low Stock await notificationService.checkLowStock(item._id.toString()); res.json(item); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const consumeStock = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { quantityConsumed, requester, date } = req.body; if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' }); if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' }); const item = await StockItem.findOne({ _id: id, organizationId }); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' }); item.quantity -= Number(quantityConsumed); await item.save(); // Calculate next movement number const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 }); const count = await StockMovement.countDocuments({ stockItemId: item._id }); const movementNumber = (lastMov?.movementNumber || count) + 1; // Register Movement (Negative quantity for consumption) await StockMovement.create({ organizationId, createdBy: req.appUser?._id, stockItemId: item._id, movementNumber, type: 'CONSUMPTION', quantity: -Number(quantityConsumed), // Negative responsible: userName, requester, date: date || new Date() }); // Check Low Stock await notificationService.checkLowStock(item._id.toString()); res.json(item); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const deleteStockItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; // Optional: Block delete if there are movements other than ENTRY? // For simplicity allow Admin to nuke it. const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId }); if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' }); // Cleanup movements & logs await StockMovement.deleteMany({ stockItemId: id }); await StockAuditLog.deleteMany({ stockItemId: id }); res.status(204).send(); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const getStockItems = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; const { dataSheetId } = req.query; // eslint-disable-next-line @typescript-eslint/no-explicit-any const query: any = { organizationId }; if (dataSheetId) query.dataSheetId = dataSheetId; // Sort by Expiration Date ASC (First to expire first) const items = await StockItem.find(query) .populate('dataSheetId', 'name manufacturer type') .sort({ expirationDate: 1 }); res.json(items); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const getStockItemById = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; const item = await StockItem.findOne({ _id: id, organizationId }) .populate('dataSheetId', 'name manufacturer type'); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); res.json(item); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; export const getStockMovements = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; // StockItem ID const organizationId = req.appUser?.organizationId; const movements = await StockMovement.find({ stockItemId: id, organizationId }) .sort({ date: -1 }); res.json(movements); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } }; // ------------------------------------------------------------------ // CRUD & Auditing for Movements // ------------------------------------------------------------------ import StockAuditLog from '../models/StockAuditLog.js'; export const updateStockMovement = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; // Movement ID const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const userId = req.appUser?._id || 'system'; const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin'; if (!isAdmin) { return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' }); } const { date, quantity, notes } = req.body; const movement = await StockMovement.findOne({ _id: id, organizationId }); if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId }); if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' }); // Calculate Delta // If quantity changed, we need to adjust the item balance // Note: 'quantity' in movement is signed (+ for entry, - for consumption) // If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed? // Let's assume the frontend sends the SIGNED value consistent with the movement type? // Actually best to stick to specific logic: // If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment). // If CONSUMPTION, quantity is stored negative. // Let's expect the frontend to send the 'raw' new value. // Be careful: if frontend sends positive 10 for a consumption, we must flip it? // Let's assume frontend sends the value exactly as it should be stored. // HOWEVER, it's safer if we check type. const newQuantitySigned = Number(quantity); // Validation: Consumption should generally be negative, Entry positive. // But for flexibility let's just trust the arithmetic diff for now, // but warn if sign flips unexpectedly? const oldQuantity = Number(movement.quantity); const quantityDiff = newQuantitySigned - oldQuantity; // Update Item const newStockLevel = Number(item.quantity) + quantityDiff; if (newStockLevel < 0) { return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' }); } item.quantity = newStockLevel; await item.save(); // Audit Log const typeMap: Record = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' }; const typeLabel = typeMap[movement.type] || movement.type; await StockAuditLog.create({ organizationId, stockItemId: item._id, movementId: movement._id, movementNumber: movement.movementNumber, userId, userName, action: 'UPDATE', details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`, oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes }, newValues: { date, quantity: newQuantitySigned, notes } }); // Update Movement movement.quantity = newQuantitySigned; if (date) movement.date = date; if (notes !== undefined) movement.notes = notes; await movement.save(); res.json(movement); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error updating movement:', error); res.status(500).json({ error: message }); } }; export const deleteStockMovement = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const userId = req.appUser?._id || 'system'; const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin'; if (!isAdmin) { return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' }); } const movement = await StockMovement.findOne({ _id: id, organizationId }); if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId }); if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' }); // Reverse the effect // If we delete an Entry (+10), we MUST subtract 10 from Item. // If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item. // So: Item.quantity -= movement.quantity const reverseQty = Number(movement.quantity); const newStockLevel = Number(item.quantity) - reverseQty; if (newStockLevel < 0) { return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' }); } item.quantity = newStockLevel; await item.save(); // Audit Log const typeMap: Record = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' }; const typeLabel = typeMap[movement.type] || movement.type; await StockAuditLog.create({ organizationId, stockItemId: item._id, movementId: movement._id, movementNumber: movement.movementNumber, userId, userName, action: 'DELETE', details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`, oldValues: movement.toObject() }); await StockMovement.deleteOne({ _id: id }); res.status(204).send(); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error deleting movement:', error); res.status(500).json({ error: message }); } }; export const getStockAuditLogs = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; // StockItem ID const organizationId = req.appUser?.organizationId; const logs = await StockAuditLog.find({ stockItemId: id, organizationId }) .sort({ timestamp: -1 }); res.json(logs); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); } };