503 lines
20 KiB
TypeScript
503 lines
20 KiB
TypeScript
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<string, string> = { 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<string, string> = { 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 });
|
|
}
|
|
};
|