🚀 Auto-deploy: GPI atualizado em 02/04/2026 01:12:32
This commit is contained in:
@@ -1,397 +1,72 @@
|
|||||||
import Notification, { INotification } from '../models/Notification.js';
|
import { StockItem, Instrument, Notification, TechnicalDataSheet } from '../lib/compat.js';
|
||||||
import StockItem from '../models/StockItem.js';
|
import { INotification } from '../models/Notification.js';
|
||||||
import Instrument from '../models/Instrument.js';
|
|
||||||
import { addMonths, isBefore } from 'date-fns';
|
import { addMonths, isBefore } from 'date-fns';
|
||||||
|
|
||||||
export const notificationService = {
|
export const notificationService = {
|
||||||
// Criar uma notificação
|
async create(data: Partial<INotification> & { organizationId: string }) {
|
||||||
async create(data: Partial<INotification>) {
|
return await Notification.create(data);
|
||||||
try {
|
|
||||||
const notification = new Notification(data);
|
|
||||||
await notification.save();
|
|
||||||
return notification;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating notification:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar se já existe uma notificação recente para evitar spam
|
async getByOrganization(organizationId: string) {
|
||||||
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
|
return await Notification.find({ organizationId });
|
||||||
try {
|
|
||||||
const graceDate = new Date();
|
|
||||||
graceDate.setDate(graceDate.getDate() - graceDays);
|
|
||||||
|
|
||||||
const query: Record<string, unknown> = {
|
|
||||||
organizationId: orgId
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adicionar campos de metadata à query
|
|
||||||
for (const [key, value] of Object.entries(metadata)) {
|
|
||||||
query[`metadata.${key}`] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
|
|
||||||
// Independente de estar lida ou não, para evitar duplicidade.
|
|
||||||
query.createdAt = { $gte: graceDate };
|
|
||||||
|
|
||||||
const existing = await Notification.findOne(query);
|
|
||||||
return !!existing;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking notification existence:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Obter notificações de um usuário (ou globais da organização)
|
|
||||||
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
|
||||||
try {
|
|
||||||
const query: Record<string, unknown> = {
|
|
||||||
organizationId,
|
|
||||||
$or: [
|
|
||||||
{ recipientId: userId },
|
|
||||||
{ recipientId: null } // Notificações globais
|
|
||||||
],
|
|
||||||
deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!includeArchived) {
|
|
||||||
// Filtra as arquivadas (pelo usuário ou globalmente)
|
|
||||||
query.isArchived = false;
|
|
||||||
query.archivedBy = { $ne: userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await Notification.find(query).sort({ createdAt: -1 }).limit(50);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching notifications:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Marcar como lida
|
|
||||||
async markAsRead(id: string) {
|
async markAsRead(id: string) {
|
||||||
try {
|
return await Notification.findOneAndUpdate({ id }, { isRead: true });
|
||||||
return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking notification as read:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Marcar todas como lidas para um usuário
|
async delete(id: string) {
|
||||||
async markAllAsRead(userId: string, organizationId: string) {
|
return await Notification.findByIdAndDelete(id);
|
||||||
try {
|
|
||||||
return await Notification.updateMany(
|
|
||||||
{
|
|
||||||
organizationId,
|
|
||||||
$or: [
|
|
||||||
{ recipientId: userId },
|
|
||||||
{ recipientId: null }
|
|
||||||
],
|
|
||||||
isRead: false
|
|
||||||
},
|
|
||||||
{ isRead: true }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking all notifications as read:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Arquivar uma notificação para um usuário
|
|
||||||
async archive(id: string, userId: string) {
|
|
||||||
try {
|
|
||||||
const notification = await Notification.findById(id);
|
|
||||||
if (!notification) return null;
|
|
||||||
|
|
||||||
if (notification.recipientId) {
|
|
||||||
// Notificação pessoal
|
|
||||||
notification.isArchived = true;
|
|
||||||
notification.isRead = true;
|
|
||||||
} else {
|
|
||||||
// Notificação global
|
|
||||||
if (!notification.archivedBy.includes(userId)) {
|
|
||||||
notification.archivedBy.push(userId);
|
|
||||||
}
|
|
||||||
// Marcar como lida também? Opcional
|
|
||||||
if (!notification.readBy?.includes(userId)) {
|
|
||||||
// Nota: se quisermos readBy global, precisaríamos desse campo.
|
|
||||||
// Para simplificar, vamos assumir que arquivar esconde da lista ativa.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await notification.save();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error archiving notification:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Deletar (esconder) uma notificação para um usuário
|
|
||||||
async softDelete(id: string, userId: string) {
|
|
||||||
try {
|
|
||||||
const notification = await Notification.findById(id);
|
|
||||||
if (!notification) return null;
|
|
||||||
|
|
||||||
if (notification.recipientId && notification.recipientId === userId) {
|
|
||||||
// Se for pessoal, podemos deletar do banco ou apenas marcar
|
|
||||||
return await Notification.findByIdAndDelete(id);
|
|
||||||
} else {
|
|
||||||
// Se for global, apenas adicionar ao deletedBy
|
|
||||||
if (!notification.deletedBy.includes(userId)) {
|
|
||||||
notification.deletedBy.push(userId);
|
|
||||||
}
|
|
||||||
return await notification.save();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error soft deleting notification:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Limpar todas (esconder todas as atuais)
|
|
||||||
async clearAll(userId: string, organizationId: string) {
|
|
||||||
try {
|
|
||||||
// Para notificações pessoais: Deletar
|
|
||||||
await Notification.deleteMany({
|
|
||||||
organizationId,
|
|
||||||
recipientId: userId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Para notificações globais: Marcar como deletadas por esse usuário
|
|
||||||
const globalNotifications = await Notification.find({
|
|
||||||
organizationId,
|
|
||||||
recipientId: null,
|
|
||||||
deletedBy: { $ne: userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const notif of globalNotifications) {
|
|
||||||
notif.deletedBy.push(userId);
|
|
||||||
await notif.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing all notifications:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar vencimentos de estoque e gerar notificações
|
|
||||||
async checkStockExpirations() {
|
|
||||||
console.log('Running stock expiration checkJob...');
|
|
||||||
try {
|
|
||||||
// Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
|
|
||||||
// Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
|
|
||||||
const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
|
||||||
|
|
||||||
for (const item of stockItems) {
|
|
||||||
if (!item.expirationDate) continue;
|
|
||||||
|
|
||||||
const expirationDate = new Date(item.expirationDate);
|
|
||||||
const itemId = item._id.toString();
|
|
||||||
const orgId = item.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
let title = '';
|
|
||||||
let type: 'warning' | 'error' = 'warning';
|
|
||||||
|
|
||||||
// Lógica de notificação
|
|
||||||
// 1. Vencido
|
|
||||||
if (isBefore(expirationDate, now)) {
|
|
||||||
title = 'Item Vencido';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'error';
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expired'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expired' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês (aprox)
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Vencimento Próximo (1 mês)';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_1_month'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'warning',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses (aprox)
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Vencimento em 2 meses';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_2_months'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'info',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in checkStockExpirations:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar calibração de instrumentos
|
|
||||||
async checkInstrumentCalibrations() {
|
|
||||||
console.log('Running instrument calibration checkJob...');
|
|
||||||
try {
|
|
||||||
const instruments = await Instrument.find({
|
|
||||||
calibrationExpirationDate: { $exists: true, $ne: null },
|
|
||||||
status: { $ne: 'inactive' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
|
||||||
|
|
||||||
for (const instrument of instruments) {
|
|
||||||
if (!instrument.calibrationExpirationDate) continue;
|
|
||||||
|
|
||||||
const expirationDate = new Date(instrument.calibrationExpirationDate);
|
|
||||||
const instrumentId = instrument._id.toString();
|
|
||||||
const orgId = instrument.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let message = '';
|
|
||||||
let type: 'info' | 'warning' | 'error' = 'info';
|
|
||||||
let triggerType = '';
|
|
||||||
|
|
||||||
// 1. Vencido
|
|
||||||
if (isBefore(expirationDate, now)) {
|
|
||||||
title = 'Calibração Vencida';
|
|
||||||
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'error';
|
|
||||||
triggerType = 'calibration_expired';
|
|
||||||
|
|
||||||
// Atualizar status para expired se não estiver
|
|
||||||
if (instrument.status !== 'expired') {
|
|
||||||
instrument.status = 'expired';
|
|
||||||
await instrument.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Calibração vence em 1 mês';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'warning';
|
|
||||||
triggerType = 'calibration_1_month';
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Calibração vence em 2 meses';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'info';
|
|
||||||
triggerType = 'calibration_2_months';
|
|
||||||
} else {
|
|
||||||
continue; // Não precisa notificar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evitar spam
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
instrumentId,
|
|
||||||
triggerType
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata: { instrumentId, triggerType }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in checkInstrumentCalibrations:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color)
|
|
||||||
async checkLowStock(stockItemId: string) {
|
async checkLowStock(stockItemId: string) {
|
||||||
try {
|
try {
|
||||||
const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer');
|
const item = await StockItem.findById(stockItemId);
|
||||||
if (!item || !item.minStock || item.minStock <= 0) return;
|
if (!item) return;
|
||||||
|
|
||||||
const orgId = item.organizationId;
|
const ds = await TechnicalDataSheet.findById(item.dataSheetId);
|
||||||
if (!orgId) return;
|
|
||||||
|
|
||||||
// Aggregate total quantity for this Product + Color
|
|
||||||
const siblings = await StockItem.find({
|
const siblings = await StockItem.find({
|
||||||
organizationId: orgId,
|
organizationId: item.organizationId,
|
||||||
dataSheetId: item.dataSheetId,
|
dataSheetId: item.dataSheetId,
|
||||||
color: item.color
|
color: item.color
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0);
|
const totalQuantity = siblings.reduce((sum: number, s: any) => sum + (Number(s.quantity) || 0), 0);
|
||||||
|
const minStock = item.minStock || 0;
|
||||||
|
|
||||||
if (totalQuantity < item.minStock) {
|
if (totalQuantity <= minStock) {
|
||||||
// Check throttling
|
await this.create({
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
organizationId: item.organizationId,
|
||||||
stockItemId: stockItemId, // Keep using specific item ID as reference or maybe composite key?
|
title: 'Estoque Baixo',
|
||||||
// Let's use a composite key for the trigger to avoid spamming for every batch in the group
|
message: `O item ${ds?.name || item.rrNumber} está com estoque baixo (${totalQuantity} ${item.unit}). Mínimo: ${minStock}.`,
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
type: 'warning',
|
||||||
triggerType: 'low_stock_aggregated'
|
metadata: { stockItemId: item.id, triggerType: 'low_stock' }
|
||||||
}, 3);
|
});
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title: 'Estoque Baixo (Total)',
|
|
||||||
message: `O produto ${item.dataSheetId?.name} (Cor: ${item.color || 'N/A'}) atingiu o nível crítico. Total: ${totalQuantity.toFixed(1)}${item.unit}. (Mínimo: ${item.minStock}${item.unit})`,
|
|
||||||
type: 'error',
|
|
||||||
metadata: {
|
|
||||||
stockItemId,
|
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
|
||||||
triggerType: 'low_stock_aggregated'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking low stock:', error);
|
console.error('Error checking low stock:', error);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkInstruments() {
|
||||||
|
try {
|
||||||
|
const instruments = await Instrument.find({});
|
||||||
|
const today = new Date();
|
||||||
|
const nextMonth = addMonths(today, 1);
|
||||||
|
|
||||||
|
for (const inst of instruments) {
|
||||||
|
if (inst.nextCalibration && isBefore(new Date(inst.nextCalibration), nextMonth)) {
|
||||||
|
await this.create({
|
||||||
|
organizationId: inst.organizationId,
|
||||||
|
title: 'Calibração Próxima',
|
||||||
|
message: `O instrumento ${inst.name} (${inst.tag}) requer calibração em ${new Date(inst.nextCalibration).toLocaleDateString()}.`,
|
||||||
|
type: 'warning',
|
||||||
|
metadata: { instrumentId: inst.id, triggerType: 'calibration_due' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking instruments:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user