Compare commits

...

21 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
Cauê Faleiros
615e07ad32 Allow DTF in campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
2026-07-22 11:27:15 -03:00
Cauê Faleiros
c07264fd69 Filter campaign products to apparel 2026-07-22 11:22:49 -03:00
Cauê Faleiros
9c77475ce5 Seed default catalog categories 2026-07-22 09:21:21 -03:00
27 changed files with 2432 additions and 208 deletions

View File

@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123 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) --- # --- 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

View File

@@ -11,4 +11,4 @@ FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -112,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL ADMIN_EMAIL
ADMIN_PASSWORD ADMIN_PASSWORD
JWT_SECRET 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 ## Validation
```bash ```bash

View File

@@ -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',
@@ -7,5 +36,7 @@ module.exports = {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
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_SITE_KEY,
TURNSTILE_SECRET
}; };

View File

@@ -5,6 +5,29 @@ const pool = new Pool({
connectionString: DATABASE_URL connectionString: DATABASE_URL
}); });
const defaultCatalogCategories = [
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
['Moletom', 'Moletons, cangurus e produtos de frio.'],
['Oversized', 'Modelagens oversized e variações relacionadas.'],
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
];
const seedDefaultCatalogCategories = async () => {
for (const [name, description] of defaultCatalogCategories) {
await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO NOTHING;
`, [name, description]);
}
};
const initDB = async () => { const initDB = async () => {
try { try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`); await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
@@ -105,6 +128,13 @@ const initDB = async () => {
quantity NUMERIC(14, 4) DEFAULT 0, quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN', unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100), 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, tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -122,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(` 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,
@@ -185,6 +279,10 @@ const initDB = async () => {
efficiency_percent NUMERIC(7, 3), efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4), rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg 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, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
); );
@@ -254,6 +352,33 @@ const initDB = async () => {
await pool.query(` await pool.query(`
ALTER TABLE production_orders 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 TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, 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 TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
@@ -294,6 +419,14 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).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(` await pool.query(`
ALTER TABLE consumption_references ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -302,6 +435,8 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
await seedDefaultCatalogCategories();
await pool.query(` await pool.query(`
ALTER TABLE supply_receipts ALTER TABLE supply_receipts
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -400,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_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_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_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_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);`);
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_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 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_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_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);`); 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, 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)));

View File

@@ -1,12 +1,59 @@
const express = require('express'); const express = require('express');
const { login } = require('../auth'); const { login } = require('../auth');
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_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 verifyCaptcha = async (captchaToken, remoteIp) => {
const { email, password } = req.body; if (!TURNSTILE_SECRET) return true;
if (!captchaToken || typeof captchaToken !== 'string') return false;
try { 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); const authResult = await login(email, password);
if (!authResult) { if (!authResult) {

View File

@@ -1,14 +1,24 @@
const express = require('express'); const express = require('express');
const { verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { const {
getCampaignPreview, getCampaignPreview,
getCampaignQueueSummary, getCampaignQueueSummary,
getTopClientsForCampaign,
processPendingStockCampaigns, processPendingStockCampaigns,
retryCampaignItems retryCampaignItems
} = require('../services/campaignService'); } = require('../services/campaignService');
const router = express.Router(); 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) => { router.get('/campaigns', verifyToken, async (req, res) => {
try { try {
res.json(await getCampaignQueueSummary()); 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) => { router.post('/campaigns/process', verifyToken, async (req, res) => {
try { try {
res.json(await processPendingStockCampaigns()); res.json(await processPendingStockCampaigns());

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService'); const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router(); 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) => { router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try { try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status)); res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));

View File

@@ -6,6 +6,26 @@ const applyProductDisplayAlias = (name) => {
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM'); .replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM');
}; };
const normalizeCampaignProductName = (name) => String(name || '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const isCampaignEligibleProductName = (name) => {
const normalizedName = normalizeCampaignProductName(name);
if (!normalizedName) return false;
if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false;
if (/\b(?:TINTA|FILME|POLIAMIDA|PO PARA DTF|ROLO DTF|FLUIDO|PRIMER|CABECA|SENSOR|FILTRO|BOMBA)\b.*\bDTF\b/.test(normalizedName)) return false;
if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false;
if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA)\b/.test(normalizedName)) return false;
if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false;
return /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName);
};
const formatProductNameForDisplay = (name) => { const formatProductNameForDisplay = (name) => {
return applyProductDisplayAlias(name) return applyProductDisplayAlias(name)
.toLocaleLowerCase('pt-BR') .toLocaleLowerCase('pt-BR')
@@ -103,5 +123,6 @@ module.exports = {
formatProductList, formatProductList,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
}; };

View File

@@ -5,14 +5,118 @@ const {
formatProductList, formatProductList,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
} = require('./campaignFormatter'); } = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100; 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 MAX_CAMPAIGN_ATTEMPTS = 3;
const CAMPAIGN_DELTA_THRESHOLD = 100; 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) => { const enqueueStockCampaignItem = async (client, item) => {
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
return false;
}
const query = ` const query = `
INSERT INTO stock_campaign_queue ( INSERT INTO stock_campaign_queue (
base_product_name, produto_id, nome, saldo, delta_estoque base_product_name, produto_id, nome, saldo, delta_estoque
@@ -26,6 +130,8 @@ const enqueueStockCampaignItem = async (client, item) => {
item.saldo, item.saldo,
item.deltaEstoque item.deltaEstoque
]); ]);
return true;
}; };
const getTopBuyersAllTime = async () => { const getTopBuyersAllTime = async () => {
@@ -46,6 +152,63 @@ const getTopBuyersAllTime = async () => {
return result.rows; 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 claimReadyCampaignItems = async () => {
const client = await pool.connect(); const client = await pool.connect();
@@ -117,7 +280,8 @@ const getCampaignQueueRows = async () => {
}; };
const getCampaignQueueSummary = async () => { const getCampaignQueueSummary = async () => {
const rows = await getCampaignQueueRows(); const rows = (await getCampaignQueueRows())
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
return { return {
threshold: CAMPAIGN_DELTA_THRESHOLD, threshold: CAMPAIGN_DELTA_THRESHOLD,
maxAttempts: MAX_CAMPAIGN_ATTEMPTS, maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
@@ -134,7 +298,8 @@ const getCampaignPreview = async () => {
AND attempts < $1 AND attempts < $1
ORDER BY created_at ASC, id ASC; ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]); `, [MAX_CAMPAIGN_ATTEMPTS]);
const groups = groupCampaignRowsByBaseProduct(result.rows); const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const readyGroups = {}; const readyGroups = {};
const belowThresholdGroups = {}; const belowThresholdGroups = {};
@@ -205,6 +370,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
`, [status, errorMessage, ids]); `, [status, errorMessage, ids]);
}; };
const skipIneligibleCampaignItems = async (rows) => {
const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome));
const ids = ineligibleRows.map(row => row.id);
await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.');
return new Set(ineligibleRows.map(row => row.base_product_name)).size;
};
const sendWhatsappCampaign = async (products, customers) => { const sendWhatsappCampaign = async (products, customers) => {
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, { const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST', method: 'POST',
@@ -231,14 +405,21 @@ const processPendingStockCampaigns = async () => {
return summary; return summary;
} }
const groups = groupCampaignRowsByBaseProduct(rows); summary.skippedGroups += await skipIneligibleCampaignItems(rows);
const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const products = mapCampaignProducts(groups); const products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds); const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime(); const customers = await getTopBuyersAllTime();
if (!products.length) {
return summary;
}
if (!customers.length) { if (!customers.length) {
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.'); await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
summary.skippedGroups = products.length; summary.skippedGroups += products.length;
return summary; return summary;
} }
@@ -260,6 +441,7 @@ module.exports = {
enqueueStockCampaignItem, enqueueStockCampaignItem,
getCampaignPreview, getCampaignPreview,
getCampaignQueueSummary, getCampaignQueueSummary,
getTopClientsForCampaign,
retryCampaignItems, retryCampaignItems,
processPendingStockCampaigns processPendingStockCampaigns
}; };

View File

@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent), efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece), 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), 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, createdAt: row.created_at,
updatedAt: row.updated_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.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.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece, 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 FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_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 const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length ? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null; : null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield) { if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento geral ou por tamanho.'); const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
INSERT INTO consumption_references ( INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields, product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece, 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 RETURNING id
`, [ `, [
productId, productId,
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
normalizeNumber(payload.gramature), normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent), normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece), normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg) normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]); ]);
const references = await listConsumptionReferences(); const references = await listConsumptionReferences();

View File

@@ -12,7 +12,7 @@ const sampleSpecs = {
orderBy: ['updated_at', 'created_at', 'id'] orderBy: ['updated_at', 'created_at', 'id']
}, },
consumption_references: { 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'] orderBy: ['updated_at', 'created_at', 'id']
}, },
cutting_family_rules: { cutting_family_rules: {
@@ -31,8 +31,16 @@ const sampleSpecs = {
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'], columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
orderBy: ['created_at', 'id'] 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: { 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'] orderBy: ['updated_at', 'created_at', 'id']
}, },
stock: { stock: {

View File

@@ -18,9 +18,22 @@ const normalizeStatus = (status) => {
const normalizeDateParam = (value) => { const normalizeDateParam = (value) => {
if (!value) return null; if (!value) return null;
const date = new Date(`${value}T00:00:00`); const normalizedValue = String(value).trim();
if (Number.isNaN(date.getTime())) return null; const isoMatch = normalizedValue.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
return value; 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) => { const formatDate = (value) => {
@@ -34,11 +47,195 @@ const formatDate = (value) => {
const normalizeText = (value) => String(value || '').trim(); const normalizeText = (value) => String(value || '').trim();
const normalizeQuantity = (value) => { 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; if (!Number.isFinite(quantity) || quantity <= 0) return 0;
return quantity; 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 mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status); const status = normalizeStatus(row.status);
@@ -56,7 +253,16 @@ const mapProductionOrderRow = (row) => {
quantity: Number(row.quantity || 0), quantity: Number(row.quantity || 0),
unit: row.unit || 'UN', unit: row.unit || 'UN',
integrationStatus: row.integration_status || '', 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) : [], 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, createdAt: row.created_at || null,
updatedAt: row.updated_at || null updatedAt: row.updated_at || null
}; };
@@ -77,27 +283,152 @@ const getOrderById = async (id, client = pool) => {
po.quantity, po.quantity,
po.unit, po.unit,
po.integration_status, 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.created_at,
po.updated_at, po.updated_at,
COALESCE( COALESCE(
JSON_AGG( (
JSON_BUILD_OBJECT( SELECT JSON_AGG(
'label', pom.label, JSON_BUILD_OBJECT(
'color', pom.color 'label', marker.label,
'color', marker.color
)
ORDER BY marker.label
) )
ORDER BY pom.label FROM production_order_markers marker
) FILTER (WHERE pom.id IS NOT NULL), WHERE marker.production_order_id = po.id
),
'[]'::json '[]'::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 FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id WHERE po.id = $1;
WHERE po.id = $1
GROUP BY po.id;
`, [id]); `, [id]);
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null; 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 listProductionOrders = async (filters = {}) => {
const params = []; const params = [];
const where = []; const where = [];
@@ -126,35 +457,8 @@ const listProductionOrders = async (filters = {}) => {
} }
const result = await pool.query(` const result = await pool.query(`
SELECT ${baseProductionOrderSelect}
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
${where.length ? `WHERE ${where.join(' AND ')}` : ''} ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY po.id
ORDER BY ORDER BY
COALESCE(po.issue_date, po.created_at::date) DESC, COALESCE(po.issue_date, po.created_at::date) DESC,
CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST, 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 updateProductionOrderStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status); const normalizedStatus = normalizeStatus(status);
@@ -312,7 +1056,10 @@ const updateProductionOrderStatus = async (id, status) => {
module.exports = { module.exports = {
createProductionOrders, createProductionOrders,
getProductComposition,
listProductCompositions,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,
upsertTinyProductionOrderDetail,
updateProductionOrderStatus updateProductionOrderStatus
}; };

View File

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

View File

@@ -6,6 +6,7 @@ const {
formatProductNameForDisplay, formatProductNameForDisplay,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
} = require('../services/campaignFormatter'); } = require('../services/campaignFormatter');
@@ -58,6 +59,39 @@ test('formatProductNameForDisplay applies customer-facing campaign aliases', ()
); );
}); });
test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => {
[
'Camiseta Premium Cor Bordo',
'Camiseta Premium Cor Preto',
'Moletom Canguru Premium Cor Preto',
'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)',
'BASE LISA CAMISETA COR PRETO TAMANHO - G',
'BASE LISA MOLETOM CANGURU COR PRETO'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), true, name);
});
[
'Ilhos Com Arruela',
'Atacador 001 Chato Preto 1,20 Mt',
'2099 Ribana 2x1 Cor Bordo',
'2001.09 Ribana 2x1 Cor Cinza',
'2001.09 Malha Camiseta 30oe Cor Cinza',
'201 Malha Camiseta 30oe Cor Preto',
'4006 Malha Camiseta 30oe Cor Marinho',
'TINTA DTF 1 LITRO - BRANCO',
'FILME DTF ROLO 60CM',
'POLIAMIDA EM PÓ PARA DTF - 1 KG',
'SALDO ESTOQUE CAMISETA'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), false, name);
});
});
test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => {
assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false);
});
test('mapCampaignProducts accumulates split deltas by base product', () => { test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([ const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }), row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),

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} - 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_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

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'; 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,27 +55,42 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end) 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 { try {
const response = await fetch(`${API_URL}/login`, { const response = await fetch(`${API_URL}/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password, captchaToken }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser }; 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_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user)); 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) { } catch (error) {
console.error('Login failed', 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>) => { 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);

View File

@@ -1,29 +1,187 @@
import { 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 {
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 Login = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); 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 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (securityMisconfigured) {
setError(securityConfigurationMessage);
return;
}
if (captchaEnabled && !captchaToken) {
setError(securityRequiredMessage);
return;
}
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const success = await login(email, password); const result = await login(email, password, captchaToken);
if (success) { if (result === 'success') {
navigate('/graph'); 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.'); 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 { } catch {
setError('Erro ao conectar ao servidor.'); setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -64,14 +222,23 @@ const Login = () => {
/> />
</div> </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 <button
type="submit" 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" 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>

View File

@@ -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>

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 { Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react'; import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
@@ -63,6 +63,16 @@ const formatQuantity = (value: number) => (
}).format(value) }).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) => ( const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || { statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto', label: fallbackLabel || 'Em aberto',
@@ -500,63 +510,136 @@ const ProductionOrders = () => {
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => { {paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel); const statusStyle = getStatusStyle(order.status, order.statusLabel);
const showDetails = hasProductionOrderDetails(order);
return ( return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50"> <Fragment key={order.id}>
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td> <tr className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td> <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"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<span className="inline-flex items-center gap-1.5"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">
<CalendarDays className="h-3.5 w-3.5" /> <span className="inline-flex items-center gap-1.5">
{formatDate(order.issueDate)} <CalendarDays className="h-3.5 w-3.5" />
</span> {formatDate(order.issueDate)}
</td> </span>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
<div className="font-bold text-dark-text">{order.productDescription}</div> <td className="px-6 py-3">
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div> <div className="font-bold text-dark-text">{order.productDescription}</div>
</td> <div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div>
<td className="px-6 py-3 text-right"> </td>
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span> <td className="px-6 py-3 text-right">
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span> <span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
</td> <span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
<td className="px-6 py-3"> </td>
{order.markers.length ? ( <td className="px-6 py-3">
<div className="flex max-w-52 flex-wrap gap-1.5"> {order.markers.length ? (
{order.markers.map(marker => ( <div className="flex max-w-52 flex-wrap gap-1.5">
<span {order.markers.map(marker => (
key={`${order.id}-${marker.label}`} <span
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" 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 className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
</span> {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> </select>
) : ( </td>
<span className="text-xs font-semibold text-dark-muted">-</span> </tr>
)} {showDetails && (
</td> <tr className="bg-dark-input/20">
<td className="px-6 py-3"> <td colSpan={9} className="px-6 pb-5 pt-1">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted"> <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]">
<span className="h-2 w-2 rounded-full bg-sky-400" /> <div>
{order.integrationStatus || 'Tiny'} <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
</span> {order.components.length ? (
</td> <div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
<td className="px-6 py-3"> <table className="w-full text-xs">
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label> <thead className="bg-dark-header text-dark-muted">
<select <tr>
id={`production-order-status-${order.id}`} <th className="px-3 py-2 text-left font-bold">Produto</th>
value={order.status} <th className="px-3 py-2 text-left font-bold">SKU</th>
disabled={busyStatusOrderId === order.id} <th className="px-3 py-2 text-right font-bold">Qtd.</th>
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)} <th className="px-3 py-2 text-right font-bold">Total</th>
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}`} </tr>
> </thead>
{editableStatusOptions.map(option => ( <tbody className="divide-y divide-dark-border">
<option key={option.value} value={option.value}>{option.label}</option> {order.components.map(component => (
))} <tr key={component.id}>
</select> <td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
</td> <td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
</tr> <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> </tbody>

View File

@@ -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>

View File

@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield; 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 Registrations = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog); 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"> <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'} {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span> </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> </div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3> <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"> <p className="mt-1 text-xs font-semibold text-dark-muted">
@@ -680,10 +697,19 @@ const Registrations = () => {
</div> </div>
<div className="flex shrink-0 items-start gap-3"> <div className="flex shrink-0 items-start gap-3">
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div> <div className="text-lg font-bold text-emerald-300">
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted"> {formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div> </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> </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"> <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" /> <Trash2 className="h-4 w-4" />

View File

@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null; 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 { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number; quantity: number;
unit: string; unit: string;
integrationStatus: string; integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
} }
@@ -156,6 +185,10 @@ export interface ConsumptionReference {
efficiencyPercent: number | null; efficiencyPercent: number | null;
ribGPerPiece: number | null; ribGPerPiece: number | null;
materialCostPerKg: number | null; materialCostPerKg: number | null;
consumptionQuantity: number | null;
consumptionUnit: string;
source: string;
lastProductionOrderId: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -197,6 +230,8 @@ export type ConsumptionReferencePayload = {
efficiencyPercent?: number | string | null; efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null; ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null; materialCostPerKg?: number | string | null;
consumptionQuantity?: number | string | null;
consumptionUnit?: string | null;
}; };
export type SupplyReceiptStatus = 'pending' | 'approved'; export type SupplyReceiptStatus = 'pending' | 'approved';
@@ -275,6 +310,8 @@ export interface SupplyPurchaseNeed {
quantitySold: number; quantitySold: number;
stockQuantity: number; stockQuantity: number;
yieldPerKg?: 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 { export interface ClientAnalyticsItem {
customerKey: string; customerKey: string;
clientToken: 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;
}