Compare commits
9 Commits
4857438e6f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b431802d50 | ||
|
|
5559e9951a | ||
|
|
9c7a383ee2 | ||
|
|
496f9d432d | ||
|
|
9a7e0d6818 | ||
|
|
6afd0c0392 | ||
|
|
c741e520ea | ||
|
|
ed3a3f0571 | ||
|
|
20dde11ff0 |
@@ -23,10 +23,12 @@ JWT_SECRET=super_secret_jwt_key_123
|
|||||||
# --- CAPTCHA / Bot Protection (Optional) ---
|
# --- CAPTCHA / Bot Protection (Optional) ---
|
||||||
# Create keys in Cloudflare Turnstile and set both values in production.
|
# Create keys in Cloudflare Turnstile and set both values in production.
|
||||||
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
|
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
TURNSTILE_SECRET=
|
TURNSTILE_SECRET=
|
||||||
|
# Backward-compatible aliases also accepted by the backend:
|
||||||
|
# VITE_TURNSTILE_SITE_KEY=
|
||||||
|
# TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# --- Frontend Configuration (Optional) ---
|
# --- Frontend Configuration (Optional) ---
|
||||||
# If you need to override the API URL for the frontend
|
# If you need to override the API URL for the frontend
|
||||||
# VITE_API_URL=/api
|
# VITE_API_URL=/api
|
||||||
# Cloudflare Turnstile site key shown on the login page
|
|
||||||
VITE_TURNSTILE_SITE_KEY=
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG VITE_TURNSTILE_SITE_KEY
|
|
||||||
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -112,13 +112,16 @@ N8N_WHATSAPP_TRIGGER_URL
|
|||||||
ADMIN_EMAIL
|
ADMIN_EMAIL
|
||||||
ADMIN_PASSWORD
|
ADMIN_PASSWORD
|
||||||
JWT_SECRET
|
JWT_SECRET
|
||||||
|
TURNSTILE_SITE_KEY
|
||||||
TURNSTILE_SECRET
|
TURNSTILE_SECRET
|
||||||
VITE_TURNSTILE_SITE_KEY
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`.
|
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
|
||||||
Set `VITE_TURNSTILE_SITE_KEY` at frontend build time to show Cloudflare Turnstile
|
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
|
||||||
on the login page.
|
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
|
## Validation
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
require('dotenv').config();
|
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 = {
|
module.exports = {
|
||||||
PORT: process.env.PORT || 3004,
|
PORT: process.env.PORT || 3004,
|
||||||
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
||||||
@@ -8,5 +37,6 @@ module.exports = {
|
|||||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
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_SECRET: process.env.TURNSTILE_SECRET || process.env.TURNSTILE_SECRET_KEY || ''
|
TURNSTILE_SITE_KEY,
|
||||||
|
TURNSTILE_SECRET
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -182,6 +182,40 @@ const initDB = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
||||||
family_key VARCHAR(20) PRIMARY KEY,
|
family_key VARCHAR(20) PRIMARY KEY,
|
||||||
@@ -504,6 +538,8 @@ const initDB = async () => {
|
|||||||
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_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_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_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_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_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_catalog_products_category_id ON catalog_products (category_id);`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
getProductDetailsAnalytics,
|
getProductDetailsAnalytics,
|
||||||
getRfmAnalytics
|
getRfmAnalytics
|
||||||
} = require('../services/analyticsService');
|
} = require('../services/analyticsService');
|
||||||
|
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { login } = require('../auth');
|
const { login } = require('../auth');
|
||||||
const { TURNSTILE_SECRET } = require('../config');
|
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
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);
|
||||||
|
|
||||||
const verifyCaptcha = async (captchaToken, remoteIp) => {
|
const verifyCaptcha = async (captchaToken, remoteIp) => {
|
||||||
if (!TURNSTILE_SECRET) return true;
|
if (!TURNSTILE_SECRET) return true;
|
||||||
@@ -34,6 +36,14 @@ const verifyCaptcha = async (captchaToken, remoteIp) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
router.post('/login', async (req, res, next) => {
|
||||||
const { email, password, captchaToken } = req.body;
|
const { email, password, captchaToken } = req.body;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const MAX_CAMPAIGN_ATTEMPTS = 3;
|
|||||||
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
||||||
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
|
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_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 = `
|
const CUSTOMER_IDENTITY_CTE = `
|
||||||
WITH customer_phone_by_name AS (
|
WITH customer_phone_by_name AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -148,19 +156,33 @@ const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
|
|||||||
const range = getTopClientsDateRange({ days, start, end });
|
const range = getTopClientsDateRange({ days, start, end });
|
||||||
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
|
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
${CUSTOMER_IDENTITY_CTE}
|
WITH campaign_orders AS (
|
||||||
|
SELECT
|
||||||
|
orders.*,
|
||||||
|
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
|
||||||
|
FROM orders
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as nome,
|
(
|
||||||
customer_key as fone,
|
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 * valor_unitario), 0) as total_gasto,
|
||||||
COALESCE(SUM(quantidade), 0) as total_comprado,
|
COALESCE(SUM(quantidade), 0) as total_comprado,
|
||||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
|
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
|
||||||
MAX(data_pedido_date) as ultima_compra
|
MAX(data_pedido_date) as ultima_compra,
|
||||||
FROM identity_orders
|
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
|
||||||
|
FROM campaign_orders
|
||||||
WHERE data_pedido_date >= $1::date
|
WHERE data_pedido_date >= $1::date
|
||||||
AND data_pedido_date <= $2::date
|
AND data_pedido_date <= $2::date
|
||||||
AND customer_key NOT LIKE 'name:%'
|
AND whatsapp_phone IS NOT NULL
|
||||||
GROUP BY customer_key
|
-- 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
|
ORDER BY total_gasto DESC
|
||||||
LIMIT $3;
|
LIMIT $3;
|
||||||
`, [range.start, range.end, normalizedLimit]);
|
`, [range.start, range.end, normalizedLimit]);
|
||||||
@@ -171,7 +193,8 @@ const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
|
|||||||
total_gasto: Number(row.total_gasto || 0),
|
total_gasto: Number(row.total_gasto || 0),
|
||||||
total_comprado: Number(row.total_comprado || 0),
|
total_comprado: Number(row.total_comprado || 0),
|
||||||
total_pedidos: Number(row.total_pedidos || 0),
|
total_pedidos: Number(row.total_pedidos || 0),
|
||||||
ultima_compra: row.ultima_compra
|
ultima_compra: row.ultima_compra,
|
||||||
|
telefones: Array.isArray(row.telefones) ? row.telefones : []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -650,9 +650,229 @@ const normalizeSteps = (steps = []) => (
|
|||||||
color: normalizeText(step.color || step.cor)
|
color: normalizeText(step.color || step.cor)
|
||||||
})).filter(step => step.name);
|
})).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 upsertTinyProductionOrderDetail = async (payload = {}) => {
|
||||||
const order = resolveTinyOrderPayload(payload);
|
const order = resolveTinyOrderPayload(payload);
|
||||||
const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity);
|
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 steps = normalizeSteps(payload.steps || payload.etapas);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
@@ -836,6 +1056,8 @@ const updateProductionOrderStatus = async (id, status) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createProductionOrders,
|
createProductionOrders,
|
||||||
|
getProductComposition,
|
||||||
|
listProductCompositions,
|
||||||
listProductionOrders,
|
listProductionOrders,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
upsertTinyProductionOrderDetail,
|
upsertTinyProductionOrderDetail,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ test('getTopClientsForCampaign returns top clients for an explicit date range',
|
|||||||
total_gasto: '1234.50',
|
total_gasto: '1234.50',
|
||||||
total_comprado: '18',
|
total_comprado: '18',
|
||||||
total_pedidos: 4,
|
total_pedidos: 4,
|
||||||
ultima_compra: '2026-07-27'
|
ultima_compra: '2026-07-27',
|
||||||
|
telefones: ['5516999999901', '5516999999902']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}), async ({ getTopClientsForCampaign }, queries) => {
|
}), async ({ getTopClientsForCampaign }, queries) => {
|
||||||
@@ -68,12 +69,15 @@ test('getTopClientsForCampaign returns top clients for an explicit date range',
|
|||||||
total_gasto: 1234.5,
|
total_gasto: 1234.5,
|
||||||
total_comprado: 18,
|
total_comprado: 18,
|
||||||
total_pedidos: 4,
|
total_pedidos: 4,
|
||||||
ultima_compra: '2026-07-27'
|
ultima_compra: '2026-07-27',
|
||||||
|
telefones: ['5516999999901', '5516999999902']
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(queries.length, 1);
|
assert.equal(queries.length, 1);
|
||||||
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
||||||
assert.match(queries[0].sql, /customer_key NOT LIKE 'name:%'/);
|
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/);
|
assert.match(queries[0].sql, /ORDER BY total_gasto DESC/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -91,3 +95,17 @@ test('getTopClientsForCampaign derives an inclusive 30 day range from the end da
|
|||||||
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
275
backend/test/productCompositionService.test.js
Normal file
275
backend/test/productCompositionService.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,11 @@ services:
|
|||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||||
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
||||||
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-${TURNSTILE_SECRET_KEY:-}}
|
- 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:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -35,8 +39,6 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
|
||||||
VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-}
|
|
||||||
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
|
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
|
||||||
container_name: graph_frontend
|
container_name: graph_frontend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -55,8 +55,20 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
|
|||||||
end: formatDateParam(dateRange.end)
|
end: formatDateParam(dateRange.end)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type LoginConfig = {
|
||||||
|
captchaRequired: boolean;
|
||||||
|
captchaConfigured: boolean;
|
||||||
|
turnstileSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
|
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> => {
|
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/login`, {
|
const response = await fetch(`${API_URL}/login`, {
|
||||||
@@ -527,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>) => {
|
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
if (!filters) return;
|
if (!filters) return;
|
||||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
import { login } from '../dataService';
|
import { getLoginConfig, login } from '../dataService';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -21,8 +21,13 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
|
||||||
const turnstileScriptId = 'turnstile-api-script';
|
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 Login = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -30,38 +35,81 @@ const Login = () => {
|
|||||||
const [captchaToken, setCaptchaToken] = useState('');
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [captchaReady, setCaptchaReady] = useState(!turnstileSiteKey);
|
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 captchaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const captchaWidgetIdRef = useRef<string | null>(null);
|
const captchaWidgetIdRef = useRef<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const captchaEnabled = Boolean(turnstileSiteKey);
|
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
|
||||||
|
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
|
||||||
|
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!turnstileSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
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 siteKey = turnstileSiteKey;
|
||||||
|
|
||||||
const renderCaptcha = () => {
|
const renderCaptcha = () => {
|
||||||
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
||||||
|
|
||||||
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
try {
|
||||||
sitekey: siteKey,
|
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
||||||
theme: 'dark',
|
sitekey: siteKey,
|
||||||
callback: (token) => {
|
theme: 'dark',
|
||||||
setCaptchaToken(token);
|
callback: (token) => {
|
||||||
setCaptchaReady(true);
|
setCaptchaToken(token);
|
||||||
},
|
setCaptchaReady(true);
|
||||||
'expired-callback': () => {
|
},
|
||||||
setCaptchaToken('');
|
'expired-callback': () => {
|
||||||
setCaptchaReady(true);
|
setCaptchaToken('');
|
||||||
},
|
setCaptchaReady(true);
|
||||||
'error-callback': () => {
|
},
|
||||||
setCaptchaToken('');
|
'error-callback': () => {
|
||||||
setCaptchaReady(false);
|
setCaptchaToken('');
|
||||||
setError('Não foi possível carregar a verificação anti-bot.');
|
setCaptchaReady(false);
|
||||||
},
|
setError(securityUnavailableMessage);
|
||||||
});
|
},
|
||||||
setCaptchaReady(true);
|
});
|
||||||
|
setCaptchaReady(true);
|
||||||
|
} catch {
|
||||||
|
setCaptchaToken('');
|
||||||
|
setCaptchaReady(false);
|
||||||
|
setError(securityConfigurationMessage);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (window.turnstile) {
|
if (window.turnstile) {
|
||||||
@@ -83,17 +131,22 @@ const Login = () => {
|
|||||||
script.addEventListener('load', renderCaptcha, { once: true });
|
script.addEventListener('load', renderCaptcha, { once: true });
|
||||||
script.addEventListener('error', () => {
|
script.addEventListener('error', () => {
|
||||||
setCaptchaReady(false);
|
setCaptchaReady(false);
|
||||||
setError('Não foi possível carregar a verificação anti-bot.');
|
setError(securityUnavailableMessage);
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|
||||||
return () => script.removeEventListener('load', renderCaptcha);
|
return () => script.removeEventListener('load', renderCaptcha);
|
||||||
}, [captchaEnabled]);
|
}, [hasValidSiteKey, turnstileSiteKey]);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (securityMisconfigured) {
|
||||||
|
setError(securityConfigurationMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (captchaEnabled && !captchaToken) {
|
if (captchaEnabled && !captchaToken) {
|
||||||
setError('Confirme a verificação anti-bot antes de entrar.');
|
setError(securityRequiredMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +158,7 @@ const Login = () => {
|
|||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
navigate('/graph');
|
navigate('/graph');
|
||||||
} else if (result === 'captcha_failed') {
|
} else if (result === 'captcha_failed') {
|
||||||
setError('A verificação anti-bot falhou. Tente novamente.');
|
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
|
||||||
if (captchaEnabled) {
|
if (captchaEnabled) {
|
||||||
setCaptchaToken('');
|
setCaptchaToken('');
|
||||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||||
@@ -169,23 +222,23 @@ const Login = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{captchaEnabled && (
|
{(captchaEnabled || securityMisconfigured) && (
|
||||||
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||||
<div ref={captchaContainerRef} className="flex justify-center" />
|
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
|
||||||
{!captchaReady && (
|
{(securityMisconfigured || !captchaReady) && (
|
||||||
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação anti-bot indisponível.</p>
|
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
|
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !captchaReady || (captchaEnabled && !captchaToken)}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker';
|
|||||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types';
|
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
|
||||||
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
|
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
|
||||||
import { parseProductName } from '../productParsing';
|
import { parseProductName } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
||||||
@@ -131,6 +131,8 @@ const ProductDetails = () => {
|
|||||||
setDateRange: (range: DateRange) => void
|
setDateRange: (range: DateRange) => void
|
||||||
}>();
|
}>();
|
||||||
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
||||||
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
||||||
@@ -160,17 +162,25 @@ const ProductDetails = () => {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
|
setComposition(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setIsCompositionLoading(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const productDetails = await fetchProductDetailsAnalytics(id, dateRange);
|
setIsCompositionLoading(true);
|
||||||
|
const [productDetails, productComposition] = await Promise.all([
|
||||||
|
fetchProductDetailsAnalytics(id, dateRange),
|
||||||
|
fetchProductComposition(id)
|
||||||
|
]);
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setDetails(productDetails);
|
setDetails(productDetails);
|
||||||
|
setComposition(productComposition);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setIsCompositionLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -403,6 +413,57 @@ const ProductDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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="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 className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
|
|||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
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 { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
@@ -171,12 +171,15 @@ const Products = () => {
|
|||||||
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||||
|
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
|
||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||||
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
||||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||||
|
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
@@ -236,16 +239,20 @@ const Products = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFilterMenuOpen) return;
|
if (!isFilterMenuOpen && !isExportMenuOpen) return;
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
if (!exportMenuRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +263,7 @@ const Products = () => {
|
|||||||
document.removeEventListener('pointerdown', handlePointerDown);
|
document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isFilterMenuOpen]);
|
}, [isFilterMenuOpen, isExportMenuOpen]);
|
||||||
|
|
||||||
const productsData = useMemo<ProductRow[]>(() => {
|
const productsData = useMemo<ProductRow[]>(() => {
|
||||||
const days = getRangeDays(dateRange);
|
const days = getRangeDays(dateRange);
|
||||||
@@ -453,34 +460,68 @@ const Products = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<div ref={exportMenuRef} className="relative">
|
||||||
onClick={() => {
|
<button
|
||||||
const exportData = productsData.map(product => ({
|
type="button"
|
||||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
onClick={() => setIsExportMenuOpen(current => !current)}
|
||||||
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
|
aria-expanded={isExportMenuOpen}
|
||||||
'Descrição': product.name,
|
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"
|
||||||
'SKUs': product.skuCount,
|
title="Escolher exportação"
|
||||||
'Cores': product.colors.join(' | '),
|
>
|
||||||
'Tamanhos': product.sizes.join(' | '),
|
<Download size={16} className="text-brand-primary" />
|
||||||
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
<span>Exportar</span>
|
||||||
'Cor principal': product.topColor,
|
</button>
|
||||||
'Tamanho principal': product.topSize,
|
{isExportMenuOpen && (
|
||||||
'Status': product.riskLabel,
|
<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">
|
||||||
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
|
<button
|
||||||
'Total Vendido (un.)': product.quantitySold,
|
type="button"
|
||||||
'Estoque': product.stock,
|
onClick={() => {
|
||||||
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
|
const exportData = productsData.map(product => ({
|
||||||
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
|
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||||
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
|
||||||
}));
|
'Descrição': product.name,
|
||||||
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
'SKUs': product.skuCount,
|
||||||
}}
|
'Cores': product.colors.join(' | '),
|
||||||
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"
|
'Tamanhos': product.sizes.join(' | '),
|
||||||
title="Exportar para CSV"
|
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||||
>
|
'Cor principal': product.topColor,
|
||||||
<Download size={16} className="text-brand-primary" />
|
'Tamanho principal': product.topSize,
|
||||||
<span className="hidden sm:inline">Exportar</span>
|
'Status': product.riskLabel,
|
||||||
</button>
|
'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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
23
src/types.ts
23
src/types.ts
@@ -475,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 {
|
export interface ClientAnalyticsItem {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
clientToken: string;
|
clientToken: string;
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL?: string;
|
readonly VITE_API_URL?: string;
|
||||||
readonly VITE_TURNSTILE_SITE_KEY?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user