From c07264fd69648238ec9db0b7f052ca379d62e095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 22 Jul 2026 11:22:49 -0300 Subject: [PATCH] Filter campaign products to apparel --- backend/services/campaignFormatter.js | 20 ++++++++++++++++ backend/services/campaignService.js | 33 ++++++++++++++++++++++---- backend/test/campaignFormatter.test.js | 31 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/backend/services/campaignFormatter.js b/backend/services/campaignFormatter.js index ad38233..20dfad1 100644 --- a/backend/services/campaignFormatter.js +++ b/backend/services/campaignFormatter.js @@ -6,6 +6,25 @@ const applyProductDisplayAlias = (name) => { .replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM'); }; +const normalizeCampaignProductName = (name) => String(name || '') + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/\s+/g, ' ') + .trim() + .toUpperCase(); + +const isCampaignEligibleProductName = (name) => { + const normalizedName = normalizeCampaignProductName(name); + if (!normalizedName) return false; + + if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false; + if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false; + if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA|DTF|IMPRESSAO|PERSONALIZADO)\b/.test(normalizedName)) return false; + if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false; + + return /\b(?:CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName); +}; + const formatProductNameForDisplay = (name) => { return applyProductDisplayAlias(name) .toLocaleLowerCase('pt-BR') @@ -103,5 +122,6 @@ module.exports = { formatProductList, groupCampaignRows, groupCampaignRowsByBaseProduct, + isCampaignEligibleProductName, mapCampaignProducts }; diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js index 994aa3b..3c9758c 100644 --- a/backend/services/campaignService.js +++ b/backend/services/campaignService.js @@ -5,6 +5,7 @@ const { formatProductList, groupCampaignRows, groupCampaignRowsByBaseProduct, + isCampaignEligibleProductName, mapCampaignProducts } = require('./campaignFormatter'); @@ -13,6 +14,10 @@ const MAX_CAMPAIGN_ATTEMPTS = 3; const CAMPAIGN_DELTA_THRESHOLD = 100; const enqueueStockCampaignItem = async (client, item) => { + if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) { + return false; + } + const query = ` INSERT INTO stock_campaign_queue ( base_product_name, produto_id, nome, saldo, delta_estoque @@ -26,6 +31,8 @@ const enqueueStockCampaignItem = async (client, item) => { item.saldo, item.deltaEstoque ]); + + return true; }; const getTopBuyersAllTime = async () => { @@ -117,7 +124,8 @@ const getCampaignQueueRows = async () => { }; const getCampaignQueueSummary = async () => { - const rows = await getCampaignQueueRows(); + const rows = (await getCampaignQueueRows()) + .filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome)); return { threshold: CAMPAIGN_DELTA_THRESHOLD, maxAttempts: MAX_CAMPAIGN_ATTEMPTS, @@ -134,7 +142,8 @@ const getCampaignPreview = async () => { AND attempts < $1 ORDER BY created_at ASC, id ASC; `, [MAX_CAMPAIGN_ATTEMPTS]); - const groups = groupCampaignRowsByBaseProduct(result.rows); + const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome)); + const groups = groupCampaignRowsByBaseProduct(eligibleRows); const readyGroups = {}; const belowThresholdGroups = {}; @@ -205,6 +214,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => { `, [status, errorMessage, ids]); }; +const skipIneligibleCampaignItems = async (rows) => { + const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome)); + const ids = ineligibleRows.map(row => row.id); + + await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.'); + + return new Set(ineligibleRows.map(row => row.base_product_name)).size; +}; + const sendWhatsappCampaign = async (products, customers) => { const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, { method: 'POST', @@ -231,14 +249,21 @@ const processPendingStockCampaigns = async () => { return summary; } - const groups = groupCampaignRowsByBaseProduct(rows); + summary.skippedGroups += await skipIneligibleCampaignItems(rows); + + const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome)); + const groups = groupCampaignRowsByBaseProduct(eligibleRows); const products = mapCampaignProducts(groups); const ids = products.flatMap(product => product.itemIds); const customers = await getTopBuyersAllTime(); + if (!products.length) { + return summary; + } + if (!customers.length) { await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.'); - summary.skippedGroups = products.length; + summary.skippedGroups += products.length; return summary; } diff --git a/backend/test/campaignFormatter.test.js b/backend/test/campaignFormatter.test.js index fb96285..a254a1a 100644 --- a/backend/test/campaignFormatter.test.js +++ b/backend/test/campaignFormatter.test.js @@ -6,6 +6,7 @@ const { formatProductNameForDisplay, groupCampaignRows, groupCampaignRowsByBaseProduct, + isCampaignEligibleProductName, mapCampaignProducts } = require('../services/campaignFormatter'); @@ -58,6 +59,36 @@ test('formatProductNameForDisplay applies customer-facing campaign aliases', () ); }); +test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => { + [ + 'Camiseta Premium Cor Bordo', + 'Camiseta Premium Cor Preto', + 'Moletom Canguru Premium Cor Preto', + 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + 'BASE LISA MOLETOM CANGURU COR PRETO' + ].forEach(name => { + assert.equal(isCampaignEligibleProductName(name), true, name); + }); + + [ + 'Ilhos Com Arruela', + 'Atacador 001 Chato Preto 1,20 Mt', + '2099 Ribana 2x1 Cor Bordo', + '2001.09 Ribana 2x1 Cor Cinza', + '2001.09 Malha Camiseta 30oe Cor Cinza', + '201 Malha Camiseta 30oe Cor Preto', + '4006 Malha Camiseta 30oe Cor Marinho', + 'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)', + 'SALDO ESTOQUE CAMISETA' + ].forEach(name => { + assert.equal(isCampaignEligibleProductName(name), false, name); + }); +}); + +test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => { + assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false); +}); + test('mapCampaignProducts accumulates split deltas by base product', () => { const groups = groupCampaignRowsByBaseProduct([ row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),