Compare commits

..

18 Commits

Author SHA1 Message Date
Cauê Faleiros
b431802d50 Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
2026-07-31 10:30:49 -03:00
Cauê Faleiros
5559e9951a Support Tiny Olist V3 product compositions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m57s
2026-07-29 15:28:00 -03:00
Cauê Faleiros
9c7a383ee2 Fix duplicate top campaign clients by phone
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
2026-07-29 10:44:22 -03:00
Cauê Faleiros
496f9d432d Deduplicate top campaign clients by name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m54s
2026-07-29 09:54:22 -03:00
Cauê Faleiros
9a7e0d6818 Normalize Turnstile env values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-07-28 13:37:37 -03:00
Cauê Faleiros
6afd0c0392 Guard invalid Turnstile site key
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-28 13:24:48 -03:00
Cauê Faleiros
c741e520ea Support Portainer Turnstile env aliases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-07-28 13:10:13 -03:00
Cauê Faleiros
ed3a3f0571 Load Turnstile site key at runtime
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-07-28 13:03:51 -03:00
Cauê Faleiros
20dde11ff0 Improve Turnstile login failure message
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-07-28 12:56:17 -03:00
Cauê Faleiros
4857438e6f Reapply "Use Cloudflare Turnstile secret env name"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
This reverts commit 302b55b346.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
252c2447a8 Reapply "Add Turnstile captcha to login"
This reverts commit a7c732e139.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
a7c732e139 Revert "Add Turnstile captcha to login"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
This reverts commit 5aa1be27bb.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
302b55b346 Revert "Use Cloudflare Turnstile secret env name"
This reverts commit e8eafaf9b9.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
e8eafaf9b9 Use Cloudflare Turnstile secret env name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
2026-07-28 11:46:26 -03:00
Cauê Faleiros
5aa1be27bb Add Turnstile captcha to login
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
2026-07-28 11:34:47 -03:00
Cauê Faleiros
5c9ab6bb11 Add top campaign clients endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-07-27 14:39:44 -03:00
Cauê Faleiros
800eb976ab Derive consumption references from Tiny OP sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
2026-07-23 11:53:18 -03:00
Cauê Faleiros
c07938c94e Add Tiny production order detail sync 2026-07-23 10:42:53 -03:00
25 changed files with 2323 additions and 204 deletions

View File

@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123
# --- CAPTCHA / Bot Protection (Optional) ---
# Create keys in Cloudflare Turnstile and set both values in production.
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET=
# Backward-compatible aliases also accepted by the backend:
# VITE_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# --- Frontend Configuration (Optional) ---
# If you need to override the API URL for the frontend
# VITE_API_URL=/api

View File

@@ -112,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL
ADMIN_PASSWORD
JWT_SECRET
TURNSTILE_SITE_KEY
TURNSTILE_SECRET
```
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
the verification widget at runtime.
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
## Validation
```bash

View File

@@ -1,5 +1,34 @@
require('dotenv').config();
const TURNSTILE_KEY_MATCH = /[0-9]x[0-9A-Za-z_-]{20,}/;
const normalizeTurnstileValue = (value) => {
if (typeof value !== 'string') return '';
const trimmedValue = value.trim();
const keyMatch = trimmedValue.match(TURNSTILE_KEY_MATCH);
if (keyMatch) return keyMatch[0];
return trimmedValue.replace(/^[\s"'`{[]+|[\s"'`}\]]+$/g, '');
};
const firstEnvValue = (...values) => values
.map(normalizeTurnstileValue)
.find(Boolean) || '';
const TURNSTILE_SITE_KEY = firstEnvValue(
process.env.TURNSTILE_SITE_KEY,
process.env.TURNSTILE_SITEKEY,
process.env.VITE_TURNSTILE_SITE_KEY,
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY
);
const TURNSTILE_SECRET = firstEnvValue(
process.env.TURNSTILE_SECRET,
process.env.TURNSTILE_SECRET_KEY,
process.env.CLOUDFLARE_TURNSTILE_SECRET
);
module.exports = {
PORT: process.env.PORT || 3004,
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
@@ -7,5 +36,7 @@ module.exports = {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
TURNSTILE_SITE_KEY,
TURNSTILE_SECRET
};

View File

@@ -128,6 +128,13 @@ const initDB = async () => {
quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100),
notes TEXT,
supplier TEXT,
lot_code VARCHAR(120),
roll_quantity NUMERIC(14, 4),
fabric_kg NUMERIC(14, 4),
rib_kg NUMERIC(14, 4),
yield_pieces_per_kg NUMERIC(14, 4),
tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -145,6 +152,70 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_components (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) DEFAULT 0,
total_quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_steps (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
step_number INTEGER,
name VARCHAR(160) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(80),
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS product_compositions (
id SERIAL PRIMARY KEY,
source VARCHAR(60) NOT NULL,
external_source_id VARCHAR(120) NOT NULL,
finished_product_identity VARCHAR(255) NOT NULL,
finished_product_sku VARCHAR(255),
finished_product_description TEXT NOT NULL,
finished_product_unit VARCHAR(30) NOT NULL DEFAULT 'UN',
finished_tiny_product_id VARCHAR(100),
source_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source, finished_product_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS product_composition_components (
id SERIAL PRIMARY KEY,
product_composition_id INTEGER NOT NULL REFERENCES product_compositions(id) ON DELETE CASCADE,
component_identity VARCHAR(255) NOT NULL,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) NOT NULL DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (product_composition_id, component_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_family_rules (
family_key VARCHAR(20) PRIMARY KEY,
@@ -208,6 +279,10 @@ const initDB = async () => {
efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg NUMERIC(14, 4),
consumption_quantity NUMERIC(14, 4),
consumption_unit VARCHAR(30),
source VARCHAR(60) DEFAULT 'manual',
last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
@@ -277,6 +352,33 @@ const initDB = async () => {
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS supplier TEXT,
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_components
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_steps
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
@@ -317,6 +419,14 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30),
ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -425,11 +535,27 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
ON consumption_references (
product_id,
COALESCE(material_product_id, 0),
COALESCE(color, ''),
COALESCE(source, 'manual')
);
`).catch(err => {
console.error('Notice: Could not create unique consumption reference source index:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);

View File

@@ -10,6 +10,7 @@ const {
getProductDetailsAnalytics,
getRfmAnalytics
} = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router();
@@ -58,6 +59,25 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
}
});
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
try {
const composition = await getProductComposition(req.params.productId);
res.json({ composition });
} catch (error) {
console.error('Error fetching product composition:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try {
res.json({ compositions: await listProductCompositions() });
} catch (error) {
console.error('Error exporting product compositions:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

@@ -1,12 +1,59 @@
const express = require('express');
const { login } = require('../auth');
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
const router = express.Router();
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const TURNSTILE_SITE_KEY_PATTERN = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
const hasValidTurnstileSiteKey = TURNSTILE_SITE_KEY_PATTERN.test(TURNSTILE_SITE_KEY);
router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const verifyCaptcha = async (captchaToken, remoteIp) => {
if (!TURNSTILE_SECRET) return true;
if (!captchaToken || typeof captchaToken !== 'string') return false;
try {
const formData = new URLSearchParams({
secret: TURNSTILE_SECRET,
response: captchaToken
});
if (remoteIp) {
formData.set('remoteip', remoteIp);
}
const response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
body: formData
});
if (!response.ok) return false;
const result = await response.json();
return result.success === true;
} catch (error) {
console.error('Captcha verification failed', error);
return false;
}
};
router.get('/login/config', (req, res) => {
res.json({
captchaRequired: Boolean(TURNSTILE_SECRET),
turnstileSiteKey: hasValidTurnstileSiteKey ? TURNSTILE_SITE_KEY : '',
captchaConfigured: Boolean(TURNSTILE_SECRET && hasValidTurnstileSiteKey)
});
});
router.post('/login', async (req, res, next) => {
const { email, password, captchaToken } = req.body;
try {
const captchaValid = await verifyCaptcha(captchaToken, req.ip);
if (!captchaValid) {
res.status(403).json({ error: 'Captcha verification failed' });
return;
}
const authResult = await login(email, password);
if (!authResult) {

View File

@@ -1,14 +1,24 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { authenticateAPIKey, verifyToken } = require('../auth');
const {
getCampaignPreview,
getCampaignQueueSummary,
getTopClientsForCampaign,
processPendingStockCampaigns,
retryCampaignItems
} = require('../services/campaignService');
const router = express.Router();
const verifyCampaignExportAccess = (req, res, next) => {
if (req.headers['x-api-key']) {
authenticateAPIKey(req, res, next);
return;
}
verifyToken(req, res, next);
};
router.get('/campaigns', verifyToken, async (req, res) => {
try {
res.json(await getCampaignQueueSummary());
@@ -27,6 +37,15 @@ router.get('/campaigns/preview', verifyToken, async (req, res) => {
}
});
router.get('/campaigns/top-clients', verifyCampaignExportAccess, async (req, res) => {
try {
res.json(await getTopClientsForCampaign(req.query || {}));
} catch (error) {
console.error('Error fetching top campaign clients:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/campaigns/process', verifyToken, async (req, res) => {
try {
res.json(await processPendingStockCampaigns());

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router();
@@ -23,6 +23,15 @@ router.post('/production-orders', verifyToken, async (req, res) => {
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
} catch (error) {
console.error('Error syncing Tiny production order:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));

View File

@@ -10,8 +10,107 @@ const {
} = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100;
const TOP_CLIENTS_DEFAULT_DAYS = 30;
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
const TOP_CLIENTS_MAX_LIMIT = 5000;
const MAX_CAMPAIGN_ATTEMPTS = 3;
const CAMPAIGN_DELTA_THRESHOLD = 100;
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')";
const WHATSAPP_CUSTOMER_PHONE_SQL = `
CASE
WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL}
WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL}
ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL}
END
`;
const CUSTOMER_IDENTITY_CTE = `
WITH customer_phone_by_name AS (
SELECT
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as canonical_phone
FROM orders
WHERE NULLIF(cliente_fone, '') IS NOT NULL
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
GROUP BY normalized_customer_name
),
identity_orders AS (
SELECT
orders.*,
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
COALESCE(
NULLIF(orders.cliente_fone, ''),
customer_phone_by_name.canonical_phone,
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
) as customer_key
FROM orders
LEFT JOIN customer_phone_by_name
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
)
`;
const normalizeDateParam = (value) => {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return `${yearValue}-${monthValue}-${dayValue}`;
};
const parsePositiveInteger = (value, defaultValue, maxValue) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
return Math.min(parsed, maxValue);
};
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(date);
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${partMap.year}-${partMap.month}-${partMap.day}`;
};
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() - daysToSubtract);
return date.toISOString().slice(0, 10);
};
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
return {
days: normalizedDays,
start: normalizedStart,
end: normalizedEnd
};
};
const enqueueStockCampaignItem = async (client, item) => {
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
@@ -53,6 +152,63 @@ const getTopBuyersAllTime = async () => {
return result.rows;
};
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
const range = getTopClientsDateRange({ days, start, end });
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
const result = await pool.query(`
WITH campaign_orders AS (
SELECT
orders.*,
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
FROM orders
)
SELECT
(
ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as nome,
(
ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
FILTER (WHERE whatsapp_phone IS NOT NULL)
)[1] as fone,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
COALESCE(SUM(quantidade), 0) as total_comprado,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
MAX(data_pedido_date) as ultima_compra,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
FROM campaign_orders
WHERE data_pedido_date >= $1::date
AND data_pedido_date <= $2::date
AND whatsapp_phone IS NOT NULL
-- A campaign recipient is a WhatsApp destination, so each normalized
-- phone number must produce exactly one exported customer.
GROUP BY whatsapp_phone
ORDER BY total_gasto DESC
LIMIT $3;
`, [range.start, range.end, normalizedLimit]);
const customers = result.rows.map(row => ({
nome: row.nome,
fone: row.fone,
total_gasto: Number(row.total_gasto || 0),
total_comprado: Number(row.total_comprado || 0),
total_pedidos: Number(row.total_pedidos || 0),
ultima_compra: row.ultima_compra,
telefones: Array.isArray(row.telefones) ? row.telefones : []
}));
return {
campaign: 'top_clients',
days: range.days,
start: range.start,
end: range.end,
limit: normalizedLimit,
count: customers.length,
generated_at: new Date().toISOString(),
customers
};
};
const claimReadyCampaignItems = async () => {
const client = await pool.connect();
@@ -285,6 +441,7 @@ module.exports = {
enqueueStockCampaignItem,
getCampaignPreview,
getCampaignQueueSummary,
getTopClientsForCampaign,
retryCampaignItems,
processPendingStockCampaigns
};

View File

@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity),
consumptionUnit: row.consumption_unit || '',
source: row.source || 'manual',
lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id),
createdAt: row.created_at,
updatedAt: row.updated_at
});
@@ -185,7 +189,8 @@ const listConsumptionReferences = async () => {
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
r.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
r.material_cost_per_kg, r.created_at, r.updated_at
r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit,
r.source, r.last_production_order_id, r.created_at, r.updated_at
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -208,9 +213,10 @@ const createConsumptionReference = async (payload) => {
const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield) {
const error = new Error('Informe o rendimento geral ou por tamanho.');
if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400;
throw error;
}
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece,
material_cost_per_kg, updated_at
material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP)
RETURNING id
`, [
productId,
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg)
normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]);
const references = await listConsumptionReferences();

View File

@@ -12,7 +12,7 @@ const sampleSpecs = {
orderBy: ['updated_at', 'created_at', 'id']
},
consumption_references: {
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'created_at', 'updated_at'],
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
cutting_family_rules: {
@@ -31,8 +31,16 @@ const sampleSpecs = {
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
orderBy: ['created_at', 'id']
},
production_order_components: {
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_order_steps: {
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_orders: {
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'created_at', 'updated_at'],
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
stock: {

View File

@@ -18,9 +18,22 @@ const normalizeStatus = (status) => {
const normalizeDateParam = (value) => {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return null;
return value;
const normalizedValue = String(value).trim();
const isoMatch = normalizedValue.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (isoMatch) {
const [, year, month, day] = isoMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
const brMatch = normalizedValue.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/);
if (brMatch) {
const [, day, month, year] = brMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return null;
};
const formatDate = (value) => {
@@ -34,11 +47,195 @@ const formatDate = (value) => {
const normalizeText = (value) => String(value || '').trim();
const normalizeQuantity = (value) => {
const quantity = Number(value);
const normalizedValue = typeof value === 'string' && value.includes(',')
? value.replace(/\./g, '').replace(',', '.')
: value;
const quantity = Number(normalizedValue);
if (!Number.isFinite(quantity) || quantity <= 0) return 0;
return quantity;
};
const normalizeNullableQuantity = (value) => {
const quantity = normalizeQuantity(value);
return quantity || null;
};
const normalizeInteger = (value) => {
const number = Number(value);
return Number.isInteger(number) ? number : null;
};
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const normalizeUnit = (value) => normalizeText(value).toLowerCase();
const normalizeCategoryName = (name) => normalizeText(name)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase();
const classifyCatalogCategoryName = (name, type) => {
const normalizedName = normalizeCategoryName(name);
if (type === 'finished_product') {
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
if (/\b(?:MOLETOM|CANGURU)\b/.test(normalizedName)) return 'Moletom';
if (/\b(?:OVERSIZE|OVERSIZED)\b/.test(normalizedName)) return 'Oversized';
if (/\bINFANTIL\b/.test(normalizedName)) return 'Camiseta infantil';
if (/\b(?:BONE|ACESSORIO|ACESSORIOS)\b/.test(normalizedName)) return 'Acessórios';
return 'Camiseta regular';
}
if (/\b(?:MALHA|TECIDO|RIBANA|FIO)\b/.test(normalizedName)) return 'Malha';
if (/\b(?:ETIQUETA|TAG|EMBALAGEM|SACO|SACOLA)\b/.test(normalizedName)) return 'Embalagens';
if (/\b(?:ATACADOR|ILHOS|LINHA|FITA)\b/.test(normalizedName)) return 'Aviamentos';
if (/\bDTF\b/.test(normalizedName)) return 'DTF';
return 'Insumos gerais';
};
const ensureCategoryId = async (client, name) => {
const result = await client.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE
SET updated_at = catalog_categories.updated_at
RETURNING id;
`, [name, 'Categoria criada automaticamente pela sincronização de produção.']);
return result.rows[0].id;
};
const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryName, notes }) => {
const normalizedSku = normalizeSku(sku);
const normalizedName = normalizeText(name);
if (!normalizedSku || !normalizedName) return null;
const categoryId = await ensureCategoryId(client, categoryName || classifyCatalogCategoryName(normalizedName, type));
const result = await client.query(`
INSERT INTO catalog_products (
type, sku, name, category_id, notes, updated_at
)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (sku) DO UPDATE
SET type = EXCLUDED.type,
name = EXCLUDED.name,
category_id = COALESCE(catalog_products.category_id, EXCLUDED.category_id),
notes = COALESCE(NULLIF(catalog_products.notes, ''), EXCLUDED.notes),
updated_at = CURRENT_TIMESTAMP
RETURNING id;
`, [
type,
normalizedSku,
normalizedName,
categoryId,
normalizeText(notes) || null
]);
return result.rows[0].id;
};
const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => {
if (!order.productSku || !components.length) {
return { referenceCount: 0, skippedReferenceCount: components.length };
}
const productId = await upsertCatalogProductFromSync(client, {
sku: order.productSku,
name: order.productDescription,
type: 'finished_product',
categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'),
notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.`
});
if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length };
let referenceCount = 0;
let skippedReferenceCount = 0;
for (const component of components) {
const materialId = await upsertCatalogProductFromSync(client, {
sku: component.componentSku,
name: component.componentName,
type: 'raw_material',
categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'),
notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.`
});
if (!materialId || !component.quantityPerUnit) {
skippedReferenceCount += 1;
continue;
}
const unit = normalizeUnit(component.unit);
const generalYield = unit === 'kg' ? 1 / component.quantityPerUnit : null;
const existingReferenceResult = await client.query(`
SELECT id
FROM consumption_references
WHERE product_id = $1
AND COALESCE(material_product_id, 0) = $2
AND COALESCE(color, '') = ''
AND COALESCE(source, 'manual') = 'tiny_op'
LIMIT 1;
`, [productId, materialId]);
if (existingReferenceResult.rows.length) {
await client.query(`
UPDATE consumption_references
SET general_yield = $1,
consumption_quantity = $2,
consumption_unit = $3,
last_production_order_id = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5;
`, [
generalYield,
component.quantityPerUnit,
component.unit || null,
orderId,
existingReferenceResult.rows[0].id
]);
} else {
await client.query(`
INSERT INTO consumption_references (
product_id, material_product_id, general_yield, consumption_quantity,
consumption_unit, source, last_production_order_id, updated_at
)
VALUES ($1, $2, $3, $4, $5, 'tiny_op', $6, CURRENT_TIMESTAMP);
`, [
productId,
materialId,
generalYield,
component.quantityPerUnit,
component.unit || null,
orderId
]);
}
referenceCount += 1;
}
return { referenceCount, skippedReferenceCount };
};
const mapComponent = (component) => ({
id: component.id,
componentTinyId: component.component_tiny_id || '',
componentSku: component.component_sku || '',
componentName: component.component_name || '',
quantityPerUnit: Number(component.quantity_per_unit || 0),
totalQuantity: Number(component.total_quantity || 0),
unit: component.unit || ''
});
const mapStep = (step) => ({
id: step.id,
stepNumber: step.step_number === null ? null : Number(step.step_number),
name: step.name || '',
startDate: formatDate(step.start_date),
endDate: formatDate(step.end_date),
status: step.status || '',
color: step.color || ''
});
const mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status);
@@ -56,7 +253,16 @@ const mapProductionOrderRow = (row) => {
quantity: Number(row.quantity || 0),
unit: row.unit || 'UN',
integrationStatus: row.integration_status || '',
notes: row.notes || '',
supplier: row.supplier || '',
lotCode: row.lot_code || '',
rollQuantity: row.roll_quantity === null ? null : Number(row.roll_quantity),
fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg),
ribKg: row.rib_kg === null ? null : Number(row.rib_kg),
yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg),
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
createdAt: row.created_at || null,
updatedAt: row.updated_at || null
};
@@ -77,27 +283,152 @@ const getOrderById = async (id, client = pool) => {
po.quantity,
po.unit,
po.integration_status,
po.notes,
po.supplier,
po.lot_code,
po.roll_quantity,
po.fabric_kg,
po.rib_kg,
po.yield_pieces_per_kg,
po.created_at,
po.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'label', pom.label,
'color', pom.color
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'label', marker.label,
'color', marker.color
)
ORDER BY marker.label
)
ORDER BY pom.label
) FILTER (WHERE pom.id IS NOT NULL),
FROM production_order_markers marker
WHERE marker.production_order_id = po.id
),
'[]'::json
) as markers
) as markers,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', component.id,
'component_tiny_id', component.component_tiny_id,
'component_sku', component.component_sku,
'component_name', component.component_name,
'quantity_per_unit', component.quantity_per_unit,
'total_quantity', component.total_quantity,
'unit', component.unit
)
ORDER BY component.id
)
FROM production_order_components component
WHERE component.production_order_id = po.id
),
'[]'::json
) as components,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', step.id,
'step_number', step.step_number,
'name', step.name,
'start_date', step.start_date,
'end_date', step.end_date,
'status', step.status,
'color', step.color
)
ORDER BY step.step_number NULLS LAST, step.id
)
FROM production_order_steps step
WHERE step.production_order_id = po.id
),
'[]'::json
) as steps
FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
WHERE po.id = $1
GROUP BY po.id;
WHERE po.id = $1;
`, [id]);
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null;
};
const baseProductionOrderSelect = `
SELECT
po.id,
po.tiny_id,
po.number,
po.status,
po.order_reference,
po.issue_date,
po.expected_date,
po.product_sku,
po.product_description,
po.quantity,
po.unit,
po.integration_status,
po.notes,
po.supplier,
po.lot_code,
po.roll_quantity,
po.fabric_kg,
po.rib_kg,
po.yield_pieces_per_kg,
po.created_at,
po.updated_at,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'label', marker.label,
'color', marker.color
)
ORDER BY marker.label
)
FROM production_order_markers marker
WHERE marker.production_order_id = po.id
),
'[]'::json
) as markers,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', component.id,
'component_tiny_id', component.component_tiny_id,
'component_sku', component.component_sku,
'component_name', component.component_name,
'quantity_per_unit', component.quantity_per_unit,
'total_quantity', component.total_quantity,
'unit', component.unit
)
ORDER BY component.id
)
FROM production_order_components component
WHERE component.production_order_id = po.id
),
'[]'::json
) as components,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', step.id,
'step_number', step.step_number,
'name', step.name,
'start_date', step.start_date,
'end_date', step.end_date,
'status', step.status,
'color', step.color
)
ORDER BY step.step_number NULLS LAST, step.id
)
FROM production_order_steps step
WHERE step.production_order_id = po.id
),
'[]'::json
) as steps
FROM production_orders po
`;
const listProductionOrders = async (filters = {}) => {
const params = [];
const where = [];
@@ -126,35 +457,8 @@ const listProductionOrders = async (filters = {}) => {
}
const result = await pool.query(`
SELECT
po.id,
po.tiny_id,
po.number,
po.status,
po.order_reference,
po.issue_date,
po.expected_date,
po.product_sku,
po.product_description,
po.quantity,
po.unit,
po.integration_status,
po.created_at,
po.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'label', pom.label,
'color', pom.color
)
ORDER BY pom.label
) FILTER (WHERE pom.id IS NOT NULL),
'[]'::json
) as markers
FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
${baseProductionOrderSelect}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY po.id
ORDER BY
COALESCE(po.issue_date, po.created_at::date) DESC,
CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST,
@@ -285,6 +589,446 @@ const createProductionOrders = async (orders = []) => {
}
};
const resolveTinyOrderPayload = (payload = {}) => {
const order = payload.order && typeof payload.order === 'object' ? payload.order : payload;
const quantity = normalizeQuantity(order.quantity);
const productDescription = normalizeText(order.productDescription || order.productName || order.produto || order.descricao);
if (!productDescription || !quantity) {
const error = new Error('Produto e quantidade da OP são obrigatórios.');
error.statusCode = 400;
throw error;
}
return {
tinyId: normalizeText(order.tinyId || order.tiny_id || order.idTiny || order.id),
number: normalizeText(order.number || order.numero || order.numeroOp || order.productionOrderNumber),
status: normalizeStatus(order.status || order.situacao || 'in_progress'),
orderReference: normalizeText(order.orderReference || order.reference || order.plano || order.pedidos),
issueDate: normalizeDateParam(order.issueDate || order.date || order.data),
expectedDate: normalizeDateParam(order.expectedDate || order.dataPrevista),
productSku: normalizeText(order.productSku || order.sku || order.codigo || order.codigoSku),
productDescription,
quantity,
unit: normalizeText(order.unit || order.unidade) || 'UN',
integrationStatus: normalizeText(order.integrationStatus) || 'Tiny',
notes: normalizeText(order.notes || order.observations || order.observacoes),
supplier: normalizeText(order.supplier || order.fornecedor),
lotCode: normalizeText(order.lotCode || order.lote),
rollQuantity: normalizeNullableQuantity(order.rollQuantity || order.quantidadeRolos),
fabricKg: normalizeNullableQuantity(order.fabricKg || order.quilosMalha),
ribKg: normalizeNullableQuantity(order.ribKg || order.quilosRibana),
yieldPiecesPerKg: normalizeNullableQuantity(order.yieldPiecesPerKg || order.rendimento),
rawPayload: payload
};
};
const normalizeComponents = (components = [], orderQuantity = 0) => (
Array.isArray(components) ? components : []
).map(component => {
const quantityPerUnit = normalizeQuantity(component.quantityPerUnit || component.quantity || component.quantidade);
const totalQuantity = normalizeQuantity(component.totalQuantity || component.quantidadeTotal) || quantityPerUnit * orderQuantity;
return {
componentTinyId: normalizeText(component.componentTinyId || component.idComponente || component.id_componente || component.id),
componentSku: normalizeText(component.componentSku || component.sku || component.codigo || component.code),
componentName: normalizeText(component.componentName || component.name || component.nome || component.produto),
quantityPerUnit,
totalQuantity,
unit: normalizeText(component.unit || component.unidade)
};
}).filter(component => component.componentName);
const normalizeSteps = (steps = []) => (
Array.isArray(steps) ? steps : []
).map(step => ({
stepNumber: normalizeInteger(step.stepNumber || step.number || step.nro || step.numero),
name: normalizeText(step.name || step.etapa || step.posto || step.description),
startDate: normalizeDateParam(step.startDate || step.dataInicio),
endDate: normalizeDateParam(step.endDate || step.dataFim),
status: normalizeText(step.status || step.situacao),
color: normalizeText(step.color || step.cor)
})).filter(step => step.name);
const TINY_OLIST_V3_COMPOSITION_SOURCE = 'tiny_olist_v3';
const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-';
const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX);
const getTinyProductIdFromCompositionReference = (tinyId) => (
tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
);
const normalizeCompositionComponents = (components) => {
const byIdentity = new Map();
for (const component of components) {
const componentIdentity = normalizeSku(component.componentSku)
|| normalizeText(component.componentTinyId)
|| normalizeText(component.componentName).toUpperCase();
if (!componentIdentity) continue;
byIdentity.set(componentIdentity, {
...component,
componentSku: normalizeSku(component.componentSku),
componentIdentity
});
}
return [...byIdentity.values()];
};
const mapProductCompositionRow = (row) => ({
id: Number(row.id),
source: row.source,
externalSourceId: row.external_source_id,
finishedProductSku: row.finished_product_sku || '',
finishedProductDescription: row.finished_product_description || '',
finishedProductUnit: row.finished_product_unit || 'UN',
finishedTinyProductId: row.finished_tiny_product_id || '',
sourceMetadata: row.source_metadata || {},
lastSyncedAt: row.last_synced_at || null,
components: Array.isArray(row.components) ? row.components.map(component => ({
id: Number(component.id),
componentTinyId: component.component_tiny_id || '',
componentSku: component.component_sku || '',
componentName: component.component_name || '',
quantityPerUnit: Number(component.quantity_per_unit || 0),
unit: component.unit || '',
productId: component.component_product_id || null
})) : []
});
const findProductCompositions = async (productId, client = pool) => {
const identity = normalizeText(productId);
const result = await client.query(`
SELECT
composition.id,
composition.source,
composition.external_source_id,
composition.finished_product_sku,
composition.finished_product_description,
composition.finished_product_unit,
composition.finished_tiny_product_id,
composition.source_metadata,
composition.last_synced_at,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', component.id,
'component_tiny_id', component.component_tiny_id,
'component_sku', component.component_sku,
'component_name', component.component_name,
'quantity_per_unit', component.quantity_per_unit,
'unit', component.unit,
'component_product_id', COALESCE(
(
SELECT stock_match.produto_id
FROM stock stock_match
WHERE stock_match.produto_id = component.component_tiny_id
OR stock_match.produto_id = component.component_sku
LIMIT 1
),
(
SELECT order_match.produto_id
FROM orders order_match
WHERE order_match.produto_id = component.component_tiny_id
OR order_match.produto_id = component.component_sku
LIMIT 1
)
)
)
ORDER BY component.id
)
FROM product_composition_components component
WHERE component.product_composition_id = composition.id
),
'[]'::json
) AS components
FROM product_compositions composition
WHERE composition.source = $1
AND (
$2 = ''
OR composition.finished_tiny_product_id = $2
OR composition.finished_product_sku = UPPER($2)
)
ORDER BY composition.finished_product_sku, composition.finished_product_description;
`, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]);
return result.rows.map(mapProductCompositionRow);
};
const getProductComposition = async (productId, client = pool) => (
(await findProductCompositions(productId, client))[0] || null
);
const listProductCompositions = async (client = pool) => findProductCompositions('', client);
const upsertTinyProductComposition = async (order, components) => {
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
const finishedProductSku = normalizeSku(order.productSku);
const finishedProductIdentity = finishedProductSku || finishedTinyProductId;
if (!finishedProductIdentity) {
const error = new Error('SKU ou ID Tiny do produto acabado é obrigatório para a composição.');
error.statusCode = 400;
throw error;
}
const normalizedComponents = normalizeCompositionComponents(components);
const client = await pool.connect();
try {
await client.query('BEGIN');
const existingResult = await client.query(`
SELECT id
FROM product_compositions
WHERE source = $1 AND finished_product_identity = $2
LIMIT 1;
`, [TINY_OLIST_V3_COMPOSITION_SOURCE, finishedProductIdentity]);
let compositionId = existingResult.rows[0]?.id;
const values = [
order.tinyId,
finishedProductIdentity,
finishedProductSku || null,
order.productDescription,
order.unit,
finishedTinyProductId || null,
JSON.stringify({
provider: 'Tiny/Olist V3',
notes: order.notes || null,
payload: order.rawPayload
})
];
if (compositionId) {
await client.query(`
UPDATE product_compositions
SET external_source_id = $1,
finished_product_sku = $3,
finished_product_description = $4,
finished_product_unit = $5,
finished_tiny_product_id = $6,
source_metadata = $7::jsonb,
last_synced_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $8;
`, [...values, compositionId]);
} else {
const insertResult = await client.query(`
INSERT INTO product_compositions (
source, external_source_id, finished_product_identity,
finished_product_sku, finished_product_description, finished_product_unit,
finished_tiny_product_id, source_metadata, last_synced_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, CURRENT_TIMESTAMP)
RETURNING id;
`, [
TINY_OLIST_V3_COMPOSITION_SOURCE,
...values
]);
compositionId = insertResult.rows[0].id;
}
await client.query('DELETE FROM product_composition_components WHERE product_composition_id = $1;', [compositionId]);
for (const component of normalizedComponents) {
await client.query(`
INSERT INTO product_composition_components (
product_composition_id, component_identity, component_tiny_id,
component_sku, component_name, quantity_per_unit, unit
)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5, $6, NULLIF($7, ''));
`, [
compositionId,
component.componentIdentity,
component.componentTinyId,
component.componentSku,
component.componentName,
component.quantityPerUnit,
component.unit
]);
}
await client.query('COMMIT');
return {
composition: await getProductComposition(finishedTinyProductId || finishedProductSku),
componentCount: normalizedComponents.length
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const upsertTinyProductionOrderDetail = async (payload = {}) => {
const order = resolveTinyOrderPayload(payload);
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
if (isTinyOlistV3Composition(order)) {
return upsertTinyProductComposition(order, components);
}
const steps = normalizeSteps(payload.steps || payload.etapas);
const client = await pool.connect();
try {
await client.query('BEGIN');
const existingResult = await client.query(`
SELECT id
FROM production_orders
WHERE ($1 <> '' AND tiny_id = $1)
OR ($2 <> '' AND number = $2)
ORDER BY CASE WHEN tiny_id = $1 THEN 0 ELSE 1 END
LIMIT 1;
`, [order.tinyId, order.number]);
let orderId = existingResult.rows[0]?.id;
if (orderId) {
await client.query(`
UPDATE production_orders
SET tiny_id = COALESCE(NULLIF($1, ''), tiny_id),
number = COALESCE(NULLIF($2, ''), number),
status = $3,
order_reference = NULLIF($4, ''),
issue_date = $5,
expected_date = $6,
product_sku = NULLIF($7, ''),
product_description = $8,
quantity = $9,
unit = $10,
integration_status = $11,
notes = NULLIF($12, ''),
supplier = NULLIF($13, ''),
lot_code = NULLIF($14, ''),
roll_quantity = $15,
fabric_kg = $16,
rib_kg = $17,
yield_pieces_per_kg = $18,
tiny_payload = $19::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = $20;
`, [
order.tinyId,
order.number,
order.status,
order.orderReference,
order.issueDate,
order.expectedDate,
order.productSku,
order.productDescription,
order.quantity,
order.unit,
order.integrationStatus,
order.notes,
order.supplier,
order.lotCode,
order.rollQuantity,
order.fabricKg,
order.ribKg,
order.yieldPiecesPerKg,
JSON.stringify(order.rawPayload),
orderId
]);
} else {
const insertResult = await client.query(`
INSERT INTO production_orders (
tiny_id, number, status, order_reference, issue_date, expected_date,
product_sku, product_description, quantity, unit, integration_status,
notes, supplier, lot_code, roll_quantity, fabric_kg, rib_kg,
yield_pieces_per_kg, tiny_payload
)
VALUES (
NULLIF($1, ''), NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6,
NULLIF($7, ''), $8, $9, $10, $11, NULLIF($12, ''),
NULLIF($13, ''), NULLIF($14, ''), $15, $16, $17, $18, $19::jsonb
)
RETURNING id;
`, [
order.tinyId,
order.number,
order.status,
order.orderReference,
order.issueDate,
order.expectedDate,
order.productSku,
order.productDescription,
order.quantity,
order.unit,
order.integrationStatus,
order.notes,
order.supplier,
order.lotCode,
order.rollQuantity,
order.fabricKg,
order.ribKg,
order.yieldPiecesPerKg,
JSON.stringify(order.rawPayload)
]);
orderId = insertResult.rows[0].id;
}
await client.query('DELETE FROM production_order_components WHERE production_order_id = $1;', [orderId]);
for (const component of components) {
await client.query(`
INSERT INTO production_order_components (
production_order_id, component_tiny_id, component_sku, component_name,
quantity_per_unit, total_quantity, unit
)
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, ''));
`, [
orderId,
component.componentTinyId,
component.componentSku,
component.componentName,
component.quantityPerUnit,
component.totalQuantity,
component.unit
]);
}
await client.query('DELETE FROM production_order_steps WHERE production_order_id = $1;', [orderId]);
for (const step of steps) {
await client.query(`
INSERT INTO production_order_steps (
production_order_id, step_number, name, start_date, end_date, status, color
)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''));
`, [
orderId,
step.stepNumber,
step.name,
step.startDate,
step.endDate,
step.status,
step.color
]);
}
const referenceSync = await upsertConsumptionReferencesFromComponents(client, orderId, order, components);
await client.query('COMMIT');
return {
order: await getOrderById(orderId),
componentCount: components.length,
stepCount: steps.length,
referenceCount: referenceSync.referenceCount,
skippedReferenceCount: referenceSync.skippedReferenceCount
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const updateProductionOrderStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status);
@@ -312,7 +1056,10 @@ const updateProductionOrderStatus = async (id, status) => {
module.exports = {
createProductionOrders,
getProductComposition,
listProductCompositions,
listProductionOrders,
normalizeStatus,
upsertTinyProductionOrderDetail,
updateProductionOrderStatus
};

View File

@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const normalizeUnit = (value) => {
const unit = normalizeText(value).toLowerCase();
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
return unit || 'un.';
};
const mapReceipt = (row) => ({
id: row.id,
category: row.category,
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
m.name AS material_name,
r.color,
r.general_yield,
r.size_yields
r.size_yields,
r.consumption_quantity,
r.consumption_unit,
r.source
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
listProjectDemandRows(),
listConsumptionReferenceRows()
]);
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
const referencesBySku = referenceRows.reduce((references, reference) => {
const sku = normalizeSku(reference.product_sku);
if (!sku) return references;
references.set(sku, [...(references.get(sku) || []), reference]);
return references;
}, new Map());
const needsByMaterial = new Map();
demandRows.forEach(row => {
@@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
if (suggestedQuantity <= 0) return;
const reference = referencesBySku.get(productId);
if (!reference) {
const references = referencesBySku.get(productId) || [];
if (!references.length) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
@@ -329,56 +344,81 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return;
}
const yieldPerKg = getReferenceYield(reference);
if (!yieldPerKg) {
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'project_demand',
missingReference: true,
references.forEach(reference => {
const consumptionQuantity = Number(reference.consumption_quantity || 0);
const consumptionUnit = normalizeUnit(reference.consumption_unit);
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
if (consumptionQuantity > 0) {
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
material: materialName,
plannedKg: suggestedQuantity * consumptionQuantity,
priority: 'Atenção',
unit: consumptionUnit,
source: reference.source || 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
consumptionQuantity,
consumptionUnit
}
});
return;
}
const yieldPerKg = getReferenceYield(reference);
if (!yieldPerKg) {
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'project_demand',
missingReference: true,
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity
}
});
return;
}
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: reference.source || 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity
stockQuantity,
yieldPerKg
}
});
return;
}
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
yieldPerKg
}
});
});
lots.forEach(lot => {
if (lot.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(lot.product));
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity;
});
receipts.forEach(receipt => {
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(receipt.product));
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
if (receipt.status !== 'pending') return;
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need) need.pendingKg += receipt.quantity;
});
return Array.from(needsByMaterial.values()).map(need => {

View File

@@ -0,0 +1,111 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const withCampaignService = async (queryHandler, callback) => {
const dbPath = require.resolve('../db');
const servicePath = require.resolve('../services/campaignService');
const originalDbCache = require.cache[dbPath];
const originalServiceCache = require.cache[servicePath];
const queries = [];
delete require.cache[servicePath];
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: {
pool: {
query: async (sql, params = []) => {
queries.push({ sql, params });
return queryHandler(sql, params);
}
}
}
};
try {
const service = require('../services/campaignService');
return await callback(service, queries);
} finally {
delete require.cache[servicePath];
if (originalServiceCache) {
require.cache[servicePath] = originalServiceCache;
}
if (originalDbCache) {
require.cache[dbPath] = originalDbCache;
} else {
delete require.cache[dbPath];
}
}
};
test('getTopClientsForCampaign returns top clients for an explicit date range', async () => {
await withCampaignService(async () => ({
rows: [
{
nome: 'Cliente A',
fone: '5516999999901',
total_gasto: '1234.50',
total_comprado: '18',
total_pedidos: 4,
ultima_compra: '2026-07-27',
telefones: ['5516999999901', '5516999999902']
}
]
}), async ({ getTopClientsForCampaign }, queries) => {
const result = await getTopClientsForCampaign({
start: '2026-06-28',
end: '2026-07-27',
limit: '1000'
});
assert.equal(result.start, '2026-06-28');
assert.equal(result.end, '2026-07-27');
assert.equal(result.limit, 1000);
assert.equal(result.count, 1);
assert.deepEqual(result.customers[0], {
nome: 'Cliente A',
fone: '5516999999901',
total_gasto: 1234.5,
total_comprado: 18,
total_pedidos: 4,
ultima_compra: '2026-07-27',
telefones: ['5516999999901', '5516999999902']
});
assert.equal(queries.length, 1);
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
assert.doesNotMatch(queries[0].sql, /GROUP BY COALESCE\(canonical_customer_name, whatsapp_phone\)/);
assert.match(queries[0].sql, /ARRAY_AGG\(DISTINCT whatsapp_phone\)/);
assert.match(queries[0].sql, /ORDER BY total_gasto DESC/);
});
});
test('getTopClientsForCampaign derives an inclusive 30 day range from the end date', async () => {
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
const result = await getTopClientsForCampaign({
days: '30',
end: '2026-07-27'
});
assert.equal(result.start, '2026-06-28');
assert.equal(result.end, '2026-07-27');
assert.equal(result.limit, 1000);
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
});
});
test('getTopClientsForCampaign normalizes phones and groups one row per WhatsApp number', async () => {
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
await getTopClientsForCampaign({
days: '30',
end: '2026-07-27'
});
assert.match(queries[0].sql, /regexp_replace\(COALESCE\(cliente_fone, ''\), '\\D', '', 'g'\)/);
assert.match(queries[0].sql, /WHEN length\(/);
assert.match(queries[0].sql, /'55' \|\|/);
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
});
});

View File

@@ -0,0 +1,275 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { pool } = require('../db');
const productionOrderRouter = require('../routes/productionOrderRoutes');
const analyticsRouter = require('../routes/analyticsRoutes');
const getRouteHandler = (router, method, path) => {
const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
return route.route.stack.at(-1).handle;
};
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
const invokeHandler = async (handler, req) => {
let statusCode = 200;
let body;
const res = {
status(code) {
statusCode = code;
return this;
},
json(payload) {
body = payload;
return this;
}
};
await handler(req, res);
return { statusCode, body };
};
const compositionPayload = (components = [
{
componentTinyId: '919498232',
componentName: 'ETIQUETA DE TAMANHO GG',
componentSku: 'ETIQ.T.GG',
quantityPerUnit: '1',
totalQuantity: '1',
unit: 'UN'
},
{
componentTinyId: '976059144',
componentName: '6118 MALHA CAMISETA 30OE COR CAFE',
componentSku: 'MC.30.CAF',
quantityPerUnit: '0.1851',
totalQuantity: '0.1851',
unit: 'KG'
}
]) => ({
order: {
tinyId: 'STRUCTURE-V3-976058813',
number: 'STRUCTURE-V3-BLCS.CAF.GG',
status: 'completed',
productSku: 'BLCS.CAF.GG',
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
quantity: '1',
unit: 'UN',
notes: 'Composição sincronizada da API pública Olist V3.'
},
components,
steps: []
});
const withFakeDatabase = async (run) => {
const originalConnect = pool.connect;
const originalQuery = pool.query;
const state = {
nextCompositionId: 1,
nextOrderId: 1,
compositions: [],
compositionComponents: [],
productionOrders: []
};
const query = async (sql, params = []) => {
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) {
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
return { rows: composition ? [{ id: composition.id }] : [] };
}
if (normalizedSql.startsWith('INSERT INTO product_compositions')) {
const composition = {
id: state.nextCompositionId++,
source: params[0],
external_source_id: params[1],
finished_product_identity: params[2],
finished_product_sku: params[3],
finished_product_description: params[4],
finished_product_unit: params[5],
finished_tiny_product_id: params[6],
source_metadata: JSON.parse(params[7]),
last_synced_at: '2026-07-29T12:00:00.000Z'
};
state.compositions.push(composition);
return { rows: [{ id: composition.id }] };
}
if (normalizedSql.startsWith('UPDATE product_compositions')) {
const composition = state.compositions.find(item => item.id === params[7]);
composition.external_source_id = params[0];
composition.finished_product_sku = params[2];
composition.finished_product_description = params[3];
composition.finished_product_unit = params[4];
composition.finished_tiny_product_id = params[5];
composition.source_metadata = JSON.parse(params[6]);
return { rows: [] };
}
if (normalizedSql.startsWith('DELETE FROM product_composition_components')) {
state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]);
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO product_composition_components')) {
state.compositionComponents.push({
id: state.compositionComponents.length + 1,
product_composition_id: params[0],
component_identity: params[1],
component_tiny_id: params[2],
component_sku: params[3],
component_name: params[4],
quantity_per_unit: params[5],
unit: params[6]
});
return { rows: [] };
}
if (normalizedSql.includes('FROM product_compositions composition')) {
const compositions = state.compositions.filter(item => (
item.source === params[0]
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
));
return {
rows: compositions.map(composition => ({
...composition,
components: state.compositionComponents
.filter(item => item.product_composition_id === composition.id)
.map(item => ({ ...item, component_product_id: null }))
}))
};
}
if (normalizedSql.startsWith('SELECT id FROM production_orders')) {
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO production_orders')) {
const order = {
id: state.nextOrderId++,
tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3],
issue_date: params[4], expected_date: params[5], product_sku: params[6],
product_description: params[7], quantity: params[8], unit: params[9],
integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13],
roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16],
yield_pieces_per_kg: params[17], created_at: null, updated_at: null
};
state.productionOrders.push(order);
return { rows: [{ id: order.id }] };
}
if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) {
return { rows: [] };
}
if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) {
const order = state.productionOrders.find(item => item.id === params[0]);
return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] };
}
throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`);
};
pool.query = query;
pool.connect = async () => ({ query, release() {} });
try {
await run(state);
} finally {
pool.connect = originalConnect;
pool.query = originalQuery;
}
};
test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const result = response.body;
assert.equal(response.statusCode, 201);
assert.equal(result.componentCount, 2);
assert.equal(state.compositions.length, 1);
assert.equal(state.compositionComponents.length, 2);
assert.equal(state.productionOrders.length, 0);
assert.equal(result.composition.finishedTinyProductId, '976058813');
});
});
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
await withFakeDatabase(async (state) => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
await invokeHandler(tinySyncHandler, { body: compositionPayload([
{
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
},
{
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
}
]) });
assert.equal(state.compositions.length, 1);
assert.equal(state.compositionComponents.length, 1);
assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2);
assert.equal(state.productionOrders.length, 0);
});
});
test('product composition detail lookup returns stored components', async () => {
await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } });
const composition = response.body.composition;
assert.equal(response.statusCode, 200);
assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG');
assert.equal(composition.components.length, 2);
assert.deepEqual(composition.components[0], {
id: 1,
componentTinyId: '919498232',
componentSku: 'ETIQ.T.GG',
componentName: 'ETIQUETA DE TAMANHO GG',
quantityPerUnit: 1,
unit: 'UN',
productId: null
});
});
});
test('product composition export endpoint returns every stored composition', async () => {
await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const response = await invokeHandler(productCompositionsHandler, { params: {} });
assert.equal(response.statusCode, 200);
assert.equal(response.body.compositions.length, 1);
assert.equal(response.body.compositions[0].components.length, 2);
});
});
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinySyncHandler, { body: {
order: {
tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG',
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN'
},
components: [],
steps: []
} });
const result = response.body;
assert.equal(response.statusCode, 201);
assert.equal(state.compositions.length, 0);
assert.equal(state.productionOrders.length, 1);
assert.equal(result.order.tinyId, '12345');
assert.equal(result.order.number, 'OP-12345');
});
});

View File

@@ -27,6 +27,11 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
depends_on:
- db
restart: unless-stopped

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -55,27 +55,42 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end)
});
export const login = async (email: string, password: string): Promise<boolean> => {
export type LoginConfig = {
captchaRequired: boolean;
captchaConfigured: boolean;
turnstileSiteKey: string;
};
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
export const getLoginConfig = async (): Promise<LoginConfig> => {
const response = await fetch(`${API_URL}/login/config`, { cache: 'no-store' });
if (!response.ok) throw new Error('Failed to load login config');
return response.json() as Promise<LoginConfig>;
};
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
try {
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, captchaToken }),
});
if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser };
if (!data.token || !data.user) return false;
if (!data.token || !data.user) return 'server_error';
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
return true;
return 'success';
}
return false;
if (response.status === 403) return 'captcha_failed';
return 'invalid_credentials';
} catch (error) {
console.error('Login failed', error);
return false;
return 'server_error';
}
};
@@ -524,6 +539,42 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange:
});
};
export const fetchProductComposition = async (productId: string): Promise<ProductComposition | null> => {
try {
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`);
if (!response.ok) return null;
const data = await response.json() as { composition?: ProductComposition | null };
return data.composition || null;
} catch (error) {
console.error('Fetch product composition failed', error);
return null;
}
};
export const exportProductCompositions = async (): Promise<number> => {
const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar as composições.');
}
const compositions = data?.compositions || [];
const blob = new Blob([JSON.stringify({
exportedAt: new Date().toISOString(),
compositions
}, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return compositions.length;
};
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
if (!filters) return;
if (filters.marketplace) params.set('marketplace', filters.marketplace);

View File

@@ -1,29 +1,187 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lock } from 'lucide-react';
import { login } from '../dataService';
import { getLoginConfig, login } from '../dataService';
declare global {
interface Window {
turnstile?: {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback': () => void;
'error-callback': () => void;
theme: 'dark' | 'light' | 'auto';
}
) => string;
reset: (widgetId?: string) => void;
};
}
}
const turnstileScriptId = 'turnstile-api-script';
const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.';
const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.';
const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.';
const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
const securityFailedMessage = 'Não foi possível validar a verificação de segurança. Atualize a página e tente novamente.';
const turnstileSiteKeyPattern = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConfigLoading, setIsConfigLoading] = useState(true);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [captchaRequired, setCaptchaRequired] = useState(false);
const [captchaReady, setCaptchaReady] = useState(true);
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
const captchaWidgetIdRef = useRef<string | null>(null);
const navigate = useNavigate();
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
useEffect(() => {
let isMounted = true;
const loadLoginConfig = async () => {
try {
const config = await getLoginConfig();
if (!isMounted) return;
setCaptchaRequired(config.captchaRequired);
setTurnstileSiteKey(config.turnstileSiteKey.trim());
setCaptchaReady(!config.captchaRequired);
if (config.captchaRequired && !config.captchaConfigured) {
setError(securityConfigurationMessage);
}
} catch {
if (!isMounted) return;
setCaptchaReady(false);
setError(securityConfigLoadMessage);
} finally {
if (isMounted) {
setIsConfigLoading(false);
}
}
};
void loadLoginConfig();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!hasValidSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
const siteKey = turnstileSiteKey;
const renderCaptcha = () => {
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
try {
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
sitekey: siteKey,
theme: 'dark',
callback: (token) => {
setCaptchaToken(token);
setCaptchaReady(true);
},
'expired-callback': () => {
setCaptchaToken('');
setCaptchaReady(true);
},
'error-callback': () => {
setCaptchaToken('');
setCaptchaReady(false);
setError(securityUnavailableMessage);
},
});
setCaptchaReady(true);
} catch {
setCaptchaToken('');
setCaptchaReady(false);
setError(securityConfigurationMessage);
}
};
if (window.turnstile) {
renderCaptcha();
return;
}
const existingScript = document.getElementById(turnstileScriptId);
if (existingScript) {
existingScript.addEventListener('load', renderCaptcha, { once: true });
return () => existingScript.removeEventListener('load', renderCaptcha);
}
const script = document.createElement('script');
script.id = turnstileScriptId;
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.addEventListener('load', renderCaptcha, { once: true });
script.addEventListener('error', () => {
setCaptchaReady(false);
setError(securityUnavailableMessage);
}, { once: true });
document.head.appendChild(script);
return () => script.removeEventListener('load', renderCaptcha);
}, [hasValidSiteKey, turnstileSiteKey]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (securityMisconfigured) {
setError(securityConfigurationMessage);
return;
}
if (captchaEnabled && !captchaToken) {
setError(securityRequiredMessage);
return;
}
setIsLoading(true);
setError('');
try {
const success = await login(email, password);
if (success) {
const result = await login(email, password, captchaToken);
if (result === 'success') {
navigate('/graph');
} else {
} else if (result === 'captcha_failed') {
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} else if (result === 'invalid_credentials') {
setError('E-mail ou senha incorretos.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} else {
setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
}
} catch {
setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} finally {
setIsLoading(false);
}
@@ -64,14 +222,23 @@ const Login = () => {
/>
</div>
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
{(captchaEnabled || securityMisconfigured) && (
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
{(securityMisconfigured || !captchaReady) && (
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
)}
</div>
)}
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
<button
type="submit"
disabled={isLoading}
disabled={isLoading || isConfigLoading || securityMisconfigured || !captchaReady || (captchaEnabled && !captchaToken)}
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
>
{isLoading ? 'Entrando...' : 'Entrar'}
{isLoading || isConfigLoading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div>

View File

@@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
@@ -131,6 +131,8 @@ const ProductDetails = () => {
setDateRange: (range: DateRange) => void
}>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
@@ -160,17 +162,25 @@ const ProductDetails = () => {
if (!id) {
if (isMounted) {
setDetails(null);
setComposition(null);
setIsLoading(false);
setIsCompositionLoading(false);
}
return;
}
setIsLoading(true);
const productDetails = await fetchProductDetailsAnalytics(id, dateRange);
setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
if (isMounted) {
setDetails(productDetails);
setComposition(productComposition);
setIsLoading(false);
setIsCompositionLoading(false);
}
};
@@ -403,6 +413,57 @@ const ProductDetails = () => {
</div>
</div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition && (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
</p>
)}
</div>
{isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição</div>
) : !composition ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma composição sincronizada para este produto.
</div>
) : composition.components.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Esta composição não possui insumos.
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{composition.components.map(component => (
<tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold">
{component.productId ? (
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
{component.componentName}
</Link>
) : component.componentName}
</td>
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
@@ -63,6 +63,16 @@ const formatQuantity = (value: number) => (
}).format(value)
);
const formatOptionalQuantity = (value: number | null) => (
value === null ? '-' : formatQuantity(value)
);
const hasProductionOrderDetails = (order: ProductionOrderItem) => (
order.components.length > 0 ||
order.steps.length > 0 ||
Boolean(order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg)
);
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto',
@@ -500,63 +510,136 @@ const ProductionOrders = () => {
<tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel);
const showDetails = hasProductionOrderDetails(order);
return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(order.issueDate)}
</span>
</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
<td className="px-6 py-3">
<div className="font-bold text-dark-text">{order.productDescription}</div>
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div>
</td>
<td className="px-6 py-3 text-right">
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
</td>
<td className="px-6 py-3">
{order.markers.length ? (
<div className="flex max-w-52 flex-wrap gap-1.5">
{order.markers.map(marker => (
<span
key={`${order.id}-${marker.label}`}
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
{marker.label}
</span>
<Fragment key={order.id}>
<tr className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(order.issueDate)}
</span>
</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
<td className="px-6 py-3">
<div className="font-bold text-dark-text">{order.productDescription}</div>
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div>
</td>
<td className="px-6 py-3 text-right">
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
</td>
<td className="px-6 py-3">
{order.markers.length ? (
<div className="flex max-w-52 flex-wrap gap-1.5">
{order.markers.map(marker => (
<span
key={`${order.id}-${marker.label}`}
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
{marker.label}
</span>
))}
</div>
) : (
<span className="text-xs font-semibold text-dark-muted">-</span>
)}
</td>
<td className="px-6 py-3">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span className="h-2 w-2 rounded-full bg-sky-400" />
{order.integrationStatus || 'Tiny'}
</span>
</td>
<td className="px-6 py-3">
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<select
id={`production-order-status-${order.id}`}
value={order.status}
disabled={busyStatusOrderId === order.id}
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
>
{editableStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</div>
) : (
<span className="text-xs font-semibold text-dark-muted">-</span>
)}
</td>
<td className="px-6 py-3">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span className="h-2 w-2 rounded-full bg-sky-400" />
{order.integrationStatus || 'Tiny'}
</span>
</td>
<td className="px-6 py-3">
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<select
id={`production-order-status-${order.id}`}
value={order.status}
disabled={busyStatusOrderId === order.id}
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
>
{editableStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</td>
</tr>
</select>
</td>
</tr>
{showDetails && (
<tr className="bg-dark-input/20">
<td colSpan={9} className="px-6 pb-5 pt-1">
<div className="grid grid-cols-1 gap-4 rounded-xl border border-dark-border bg-dark-card/80 p-4 xl:grid-cols-[1.4fr_1fr_1fr]">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
{order.components.length ? (
<div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
<table className="w-full text-xs">
<thead className="bg-dark-header text-dark-muted">
<tr>
<th className="px-3 py-2 text-left font-bold">Produto</th>
<th className="px-3 py-2 text-left font-bold">SKU</th>
<th className="px-3 py-2 text-right font-bold">Qtd.</th>
<th className="px-3 py-2 text-right font-bold">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{order.components.map(component => (
<tr key={component.id}>
<td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
<td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
<td className="px-3 py-2 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td>
<td className="px-3 py-2 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem composição sincronizada.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Etapas</h3>
{order.steps.length ? (
<div className="mt-3 divide-y divide-dark-border rounded-lg border border-dark-border">
{order.steps.map(step => (
<div key={step.id} className="grid grid-cols-[32px_1fr_auto] items-center gap-3 px-3 py-2 text-xs">
<span className="font-mono font-bold text-dark-muted">{step.stepNumber ?? '-'}</span>
<div>
<p className="font-bold text-dark-text">{step.name}</p>
<p className="mt-0.5 font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</p>
</div>
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} title={step.status || 'Sem status'} />
</div>
))}
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem etapas sincronizadas.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Observações</h3>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.supplier || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.lotCode || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.rollQuantity)}</dd></div>
<div><dt className="font-bold text-dark-muted">Malha kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.fabricKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Ribana kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.ribKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.yieldPiecesPerKg)}</dd></div>
</dl>
{order.notes && <p className="mt-3 whitespace-pre-wrap rounded-lg border border-dark-border bg-dark-input p-3 text-xs font-semibold text-dark-muted">{order.notes}</p>}
</div>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>

View File

@@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
@@ -171,12 +171,15 @@ const Products = () => {
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -236,16 +239,20 @@ const Products = () => {
};
useEffect(() => {
if (!isFilterMenuOpen) return;
if (!isFilterMenuOpen && !isExportMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) {
setIsFilterMenuOpen(false);
}
if (!exportMenuRef.current?.contains(event.target as Node)) {
setIsExportMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsFilterMenuOpen(false);
setIsExportMenuOpen(false);
}
};
@@ -256,7 +263,7 @@ const Products = () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFilterMenuOpen]);
}, [isFilterMenuOpen, isExportMenuOpen]);
const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange);
@@ -453,34 +460,68 @@ const Products = () => {
}}
/>
<button
onClick={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
}}
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
title="Exportar para CSV"
>
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
</button>
<div ref={exportMenuRef} className="relative">
<button
type="button"
onClick={() => setIsExportMenuOpen(current => !current)}
aria-expanded={isExportMenuOpen}
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
title="Escolher exportação"
>
<Download size={16} className="text-brand-primary" />
<span>Exportar</span>
</button>
{isExportMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
<button
type="button"
onClick={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
setIsExportMenuOpen(false);
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
>
Produtos (CSV)
</button>
<button
type="button"
disabled={isExportingCompositions}
onClick={async () => {
setIsExportingCompositions(true);
try {
await exportProductCompositions();
setIsExportMenuOpen(false);
} catch (error) {
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
} finally {
setIsExportingCompositions(false);
}
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
>
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
</button>
</div>
)}
</div>
</div>
</div>

View File

@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield;
};
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual'
);
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
if (!reference.consumptionQuantity) return '';
const unit = reference.consumptionUnit || 'un.';
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
};
const Registrations = () => {
const [searchParams] = useSearchParams();
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
@@ -663,6 +673,13 @@ const Registrations = () => {
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
reference.source === 'tiny_op'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-dark-border bg-dark-input text-dark-muted'
}`}>
{getReferenceSourceLabel(reference)}
</span>
</div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
@@ -680,10 +697,19 @@ const Registrations = () => {
</div>
<div className="flex shrink-0 items-start gap-3">
<div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
<div className="text-lg font-bold text-emerald-300">
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{reference.consumptionQuantity
? 'consumo'
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div>
{reference.consumptionQuantity && getAverageYield(reference) ? (
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
{formatNumber(getAverageYield(reference), 3)} /kg
</div>
) : null}
</div>
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
<Trash2 className="h-4 w-4" />

View File

@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null;
}
export interface ProductionOrderComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
totalQuantity: number;
unit: string;
}
export interface ProductionOrderStep {
id: number;
stepNumber: number | null;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
color: string;
}
export interface ProductionOrderItem {
id: number;
tinyId: string;
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number;
unit: string;
integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null;
updatedAt: string | null;
}
@@ -156,6 +185,10 @@ export interface ConsumptionReference {
efficiencyPercent: number | null;
ribGPerPiece: number | null;
materialCostPerKg: number | null;
consumptionQuantity: number | null;
consumptionUnit: string;
source: string;
lastProductionOrderId: number | null;
createdAt: string;
updatedAt: string;
}
@@ -197,6 +230,8 @@ export type ConsumptionReferencePayload = {
efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null;
consumptionQuantity?: number | string | null;
consumptionUnit?: string | null;
};
export type SupplyReceiptStatus = 'pending' | 'approved';
@@ -275,6 +310,8 @@ export interface SupplyPurchaseNeed {
quantitySold: number;
stockQuantity: number;
yieldPerKg?: number;
consumptionQuantity?: number;
consumptionUnit?: string;
}>;
}
@@ -438,6 +475,29 @@ export interface ProductDetailsAnalytics {
}>;
}
export interface ProductCompositionComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
unit: string;
productId: string | null;
}
export interface ProductComposition {
id: number;
source: string;
externalSourceId: string;
finishedProductSku: string;
finishedProductDescription: string;
finishedProductUnit: string;
finishedTinyProductId: string;
sourceMetadata: Record<string, unknown>;
lastSyncedAt: string | null;
components: ProductCompositionComponent[];
}
export interface ClientAnalyticsItem {
customerKey: string;
clientToken: string;

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}